Implementing SSR authentication using Firebase and Svelte
I started looking into Svelta and have been loving it so far. However, I found it difficult to do some things. I think it’s because the community is smaller than React, for example, so there aren’t many resources covering everything. This is an attempt to mitigate that.
What I want to achieve today is to have an app that handles authentication with Firebase and is still a server side rendering.
A small disclaimer, I describe this article in a detailed tutorial, but every step is functional. This means that I create a working example that only works on the client side and then modify it until I get the SSR version. If you want the polished version, I recommend going straight to the Summary section.
I’ve provided some code snippets here that you can copy and paste, but I’d also recommend you take a look at my storage. The first PR especially since it has all the necessary code for this particular article.
Enough introduction, let’s get to it.
I’m assuming you already have a Firebase project and know your way around the dashboard a bit.
We’ll start with single email/password verification. Firebase also provides OAuth integration with providers like Facebook and Google, but we won’t cover that here.
To get started, go to your Firebase authentication dashboard and go to the Login Method tab. Enable email/password login. (This also allows registration, but that is beyond the scope of this article)
Now add the WebApp if you haven’t already and add Firebase to your project. I will use here yarn
yarn install firebase
Firebase also provides you with a snippet to initialize Firebase. Copy it to src/lib/client/firebase.ts
.
However, this file would initialize firebase on import, and this file can also be added on the server side. This is problematic because this file is only for the client side. getAnalytics
would fail e.g. Then we need a little more control. We can wrap this in a function that initializes Firebase on command. But we want to reuse the instance if it was created before. This is solved by memorizing the function. I will use lodash
for that.
Let’s also initialize auth
module at once. With this in mind, we would rewrite the initialization as follows:
import { memoize } from 'lodash';
import { initializeApp } from 'firebase/app';
import { getAnalytics } from 'firebase/analytics';
import { getAuth } from 'firebase/auth';// ... Firebase Config ...
// Initialize Firebase
export const initFirebase = memoize(() => {
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
const auth = getAuth(app);
return { app, analytics, auth }
})
Note that I am already adding auth
. For that we need import { getAuth } from 'firebase/auth';
With this we now have a configuration method firebase
from another part of our application. Now we just need to import this file. We will do this in our main layout. To do this, open (or create, if you haven’t already done so) the file src/routes/+layout.svelte
and add this snippet
<script>
import { onMount } from 'svelte';
import { initFirebase } from '$lib/client/firebase';onMount(initFirebase);
</script>
From onMount
runs only on the client, we can be sure that now firebase is only initialized on the client side.
Now we need to store the user somewhere. Let’s go ahead and create src/stores/auth.ts
file to store our user object.
import { writable } from 'svelte/store';type User = {
uid: string;
email: string;
};
export const auth = writable<User | null>(null);
Now we need a way to update the store whenever the user is updated. It’s easy now, go back +layout.svelte
and add this:
// ...
import { auth as authStore } from '../stores/auth'// ...
onMount(() => {
const { auth } = initFirebase();
onAuthStateChanged(auth, authStore.set)
})
Now whenever firebase updates an authenticated user we will have it in our store. Now we can take it from there whenever we need it.
The final, ultimate piece is just the actual login and logout. Let’s go ahead and create a simple login page. I will use src/routes/login/+page.svelte
I won’t go into the details of creating the form so as not to clutter up this article, but you can always go to the repository provided check it fully. I will mention only the important parts.
I am adding a simple one onMount
function to redirect the user to the home page whenever the login is successful.
import { goto } from "$app/navigation";
import { onMount } from "svelte";onMount(() => {
return auth.subscribe((user) => {
if (user) {
goto('/')
}
});
});
I used the method to do the actual login signInWithEmailAndPassword
from firebase/auth
which receives an auth
example (we can already get it from our initializer or using getAuth
), email
a password
. The ones we need to get out of the mold.
And finally, we need to update our store every time a user is logged in. I’ll use another one for that onMount
callback on our layout, listening for validation status. This will set the user in our store when the authorization status changes.
However, there are some problems with this approach. To show them better, I created a component AuthStatus.svelte
Come src/components
. This component only shows whether someone is authenticated or not. If you checkout this versionyou would notice that after logging in you will see subsequent page refreshes No one is verified per second and change to Verified as… after a few milliseconds. This is because when we get the page from the server, we don’t get any authorization data. The server doesn’t know that someone is actually authenticated, so it returns the page as if it were visited by a guest.
So how do we give the server some context of the session we’re creating on the client?
The firebase user has built-in methods to get a token that can later be verified server-side. So I write an action that receives the token from the client and asks the browser to save it in a cookie:
// src/routes/login/+page.server.ts
export const actions: Actions = {
default: async ({ request, cookies }) => {
const formData = await request.formData();
const token = formData.get('token')?.valueOf();
if (!token || typeof token !== 'string') {
return fail(400, { message: 'Token is a required field and must be a string' });
}
cookies.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
path: '/',
secure: true
});
return { success: true };
}
};
So after getting the user from firebase we get the token and send it to this action:
formData.set('token', await user.getIdToken());
const response = await fetch(this.action, {
method: 'POST',
body: formData,
});const result = deserialize(await response.text());
if (result.type === 'success') {
await invalidateAll();
}
In this excerpt, I already include a call to invalidateAll
. This is because any data we loaded when we were an (un)authenticated user is no longer valid, such as user data, preferences, etc. It will be more useful in a moment.
For now, we receive the token on the server with every request, but we do not verify it or do anything with it. So it’s time to write the server hook and start populating the user on the server side as well.
I will use a package to validate the token from firebase on the server side firebase-admin
. Install it using
yarn add firebase-admin
To start using it, we need to provide server credentials. To do this, go to Firebase Dashboard → Project Settings → Service Accounts tab. There you will have the option to generate a new private key for the Firebase SDK.
This will download the JSON file. It is especially important to keep the data in this JSON file safe, so it is not recommended that you add it directly to your repository. I’m not going to cover how to keep it safe here, so for the sake of this article I’ll just fork it and leave it in .env.local
. This file is not git tracked and the variables here are automatically available in $env/static/private
From the client example and what you saw on the firebase page to download your private key, you can imagine how to run firebase on the server, so I won’t put the code here.
In addition to initialization, we want a function to decode the token from firebase. We will use admin.auth().verifyIdToken(token)
with a small wrapper to catch errors and return null
if they occur, the experience is not damaged if, for example, the token has expired.
export async function decodeToken(
token: string
): Promise<DecodedIdToken | null> {
if (!token) {
return null;
}
try {
initializeFirebase();
return await admin.auth().verifyIdToken(token);
} catch (err) {
console.error('An error occurred validating token', (err as Error).message);
return null;
}
}
Now that we have this feature, we need to decode the token from the cookie on every request. As I mentioned before, I will use a server hook.
export const handle = (async ({ event, resolve }) => {
const cookies = cookie.parse(event.request.headers.get('cookie') || '');
const token = cookies[SESSION_COOKIE_NAME];
if (token) {
const decodedToken = await decodeToken(token);
if (decodedToken) {
event.locals.user = decodedToken;
}
}
return resolve(event);
}) satisfies Handle;
Note that I am adding user
k event.locals
object. Local residents object is a way to share data between server-side functions, such as other hooks and loaders. If you go and copy and paste it into your hooks.server.ts
file, an error message will appear user
is not a valid property for Locals
object. If you want to fix it, rewrite Locals
interface under app.d.ts
. I’ll just rewrite it like this:
declare namespace App {
// interface Error {}
interface Locals {
user: null | {
uid: string;
email?: string;
};
}
// interface PageData {}
// interface Platform {}
}
Now every call to the server decodes the token and sets event.locals.user
. The last piece is to use this decoded user. I’ll start providing it in the loader in the main layout file like so everyone can reuse. So under src/routes/+layout.server.ts
we put this:
import type { LayoutServerLoad } from './$types';
export const load = (async (event) => {
const user = event.locals.user;
return { user }
}) satisfies LayoutServerLoad;
And finally, remember we saved our user in the store? This store was weird because it just kept an object representing the user, but it didn’t really have any logic, but now we have the user in the site store. Sounds more like a derivative business to me. Let’s update the authorization store like this:
import { page } from '$app/stores';
import { derived } from 'svelte/store';type User = {
uid: string;
email?: string;
};
export const auth = derived<typeof page, User | null>(
page,
($page, set) => {
const { user } = $page.data;
if (!user) {
set(null);
return;
}
set(user);
},
null
);
This will automatically start using the user from the session we saved. Now try to refresh the application or look at the plain HTML returned by the server. Note that we have now effectively achieved the validated data returned from the server, so we have the full SSR back.
However, one little thing remains. We cannot log out at this time. I will now add a file for this purpose src/routes/logout/+server.ts
with a simple POST
an action that deletes the cookie
import { SESSION_COOKIE_NAME } from '$lib/constants';
import type { RequestHandler } from '@sveltejs/kit';
import cookie from 'cookie';export const POST = (async () => {
return new Response('', {
headers: {
'set-cookie': cookie.serialize(SESSION_COOKIE_NAME, '', {
path: '/',
httpOnly: true,
maxAge: -1,
})
}
});
}) satisfies RequestHandler;
And I’ll just call it s fetch
every time the user clicks on Log out button
const logout = async () => {
const firebaseAuth = getAuth();
await signOut(firebaseAuth);
await fetch('/logout', { method: 'POST' });
await invalidateAll();
}
Call now invalidateAll
makes even more sense as you’ll notice that calling it will reload the user’s data without the token. Therefore, all data is reloaded without a session and updates the page accordingly.
- Currently, the only way to log in is through the login page after entering the user’s email and password. If you want to login from a different location, such as the navigation bar, and/or want to integrate with third parties, you may need to update the logic of sending the token to the server.
- The user we save comes directly from the server! Right now Firebase returns anything, but if you need, you can also load additional data into it from any source you need. Do you have an avatar saved somewhere else? No problem, just upload it to the server hook and the client will get it automatically.