DEV Community

pilcrowOnPaper
pilcrowOnPaper

Posted on

SvelteKit + Firebase: Authentication, protected routes, and persistent login

Hi! I'm new to SvelteKit (and programming in general) but there seems to be a lack of tutorials/guides for SvelteKit so here's my contribution. We will create a server-rendered website with authentication and protected routes using firebase. At the end, we'll deploy to Vercel since many tutorials miss that part. (+Tailwind CSS so it'll look decent)

Before we start...

Why?

Of course there aren’t many resources on SvelteKit but more importantly, there’s even less resource on using Firebase with SSR. More specifically, Firebase’s auth tokens expire after an hour. And while Firebase does refresh them automatically, it only does it in the frontend. Say you have a website with 2 pages:

  • A login page where authenticated users are redirected to the member-only page
  • A member-only page where unauthenticated users are redirected to the login page

that has a system that saves the user’s firebase token as a cookie (JWT). If a user comes back after a while, the user will be sent back to the login page, wait a few seconds for the token to be refreshed by Firebase, and sent back to the member-only page. We want to avoid that.

How will it work?

So there will be 3 pages: a login, signup, and member-only page. When a user creates a new account, 2 cookies will be created. The first is an auth token, which will expire in an hour. The second is a refresh token that can be used to create new auth tokens. When a user tries to access a page, we will check the validity of the auth token, and if it’s expired, create a new one with the refresh token.

If you have, for example, set up Firestore security rules, you’ll still need to login the user using client-side Firebase. Fortunately, we can login using the auth token acquired from the backend.

Quick side-note(s)

If you wondered why we can’t just use onAuthStateChanged() , Firebase has a dependency on window. That means it only runs after the page is rendered. We want to check the user and get their data when SvelteKit is rendering the page in the server.

I. Set up

Create a skeleton SvelteKit project and add Tailwind CSS. Run npm run dev to make sure it's working. Add src/lib folder and we will out out js/ts files inside it.

We'll create 3 pages:

  • src/routes/index.svelte: member-only page
  • src/routes/login.svelte: login page
  • src/routes/signup.svelte: for new users

and your src folder should look something like this:

src
|-lib
|-routes
  |-__layout.svelte
  |-index.svelte
  |-login.svelte
  |-signup.svelte
|-app.css
|-app.dts
|-app.html

Enter fullscreen mode Exit fullscreen mode

The login page will take 2 user inputs (email, passwors) and the signup page with take 3 inputs (username, email, password). You can add additional user data if you want. Here’s some screenshots for reference:

/login

/signup

/

After that we will create 3 endpoints:

  • src/routes/api/auth.json.js: Authenticating the user
  • src/routes/api/new-user.json.js: Creating a new account
  • src/routes/api/signout.json.js: Signing out the user

II. Adding Firebase

Install firebase:

npm install firebase
Enter fullscreen mode Exit fullscreen mode

If you haven’t done it yet, create a Firebase account and a new project. Enable Firebase authentication and email/password authentication in ‘Sign-in providers’. Go to (Settings) > ‘Project settings’ and copy your firebaseConfig. In a new folder called src/lib/firebase.js paste it like this:

import { initializeApp } from "firebase/app";
import { getAuth, setPersistence, browserSessionPersistence } from "firebase/auth"

const firebaseConfig = {
  apiKey: [API_KEY],
  authDomain: [AUTH_DOMAIN],
  projectId: [PROJECT_ID],
  storageBucket: [STORAGE_BUCKET],
  messagingSenderId: [MESSAGING_SENDER_ID],
  appId: [APP_ID]
};

const app = initializeApp(firebaseConfig, "CLIENT");
export const auth = getAuth(app)
setPersistence(auth, browserSessionPersistence)
Enter fullscreen mode Exit fullscreen mode

You don’t have to hide it but if you’re worried use env variables. Make sure to name your app CLIENT since we will initialize another app. I also set persistence to browserSessionPersistence just in case to prevent unintended behavior. It makes your client side auth session (the one mentioned in ‘How does it work?’ and not the entire auth session) only last until the user closes their browser.

Next we’ll set up Firebase Admin. (Settings) > ‘Project settings’ > ‘Service accounts’ and click ‘Generate new private key’ to download JSON with you config. Add that JSON file in your project file and initialize it in src/lib/firebase-admin.json.

import admin from "firebase-admin"

import * as credential from "[PATH_TO_JSON_FILE.json]"

admin.initializeApp({
    credential: admin.credential.cert(credential)
});

export const auth = admin.auth
Enter fullscreen mode Exit fullscreen mode

III. Creating a new account

When a user creates a new account, send their username, email, and password in a POST request to ‘/api/new-user.json’. The endpoint will:

  1. Create a new account
  2. Set custom claims of the user (custom claims are user data that you can add)
  3. Sign in as the user
  4. Create a custom token
  5. Set custom token and refresh token as cookie

You’ll need to get an API key from ‘Web API key’ in (Setting) > ‘Project settings’.

src/routes/api/new-user/json.js:

import { dev } from '$app/env';
import { auth } from '$lib/firebase-admin';

const key = [WEB_API_KEY]
const secure = dev ? '' : ' Secure;'

export const post = async (event) => {
    const { email, password, username } = await event.request.json()
    const userRecord = await auth().createUser({
        email,
        password,
        displayName: username
    })
    const uid = userRecord.uid
    await auth().setCustomUserClaims(uid, { 'early_access': true })
    const signIn_res = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${key}`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ email, password, 'returnSecureToken': true })
    })
    if (!signIn_res.ok) return { status: signIn_res.status}
    const { refreshToken } = await signIn_res.json()
    const customToken = await auth().createCustomToken(uid)
    return {
        status: 200,
        headers: {
            'set-cookie': [
                `customToken=${customToken}; Max-Age=${60 * 55}; Path=/;${secure} HttpOnly`,
                `refreshToken=${refreshToken}; Max-Age=${60 * 60 * 24 * 30}; Path=/;${secure} HttpOnly`,
            ],
            'cache-control': 'no-store'
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

‘identitytoolkit.googleapis.com’ is Firebase/Google’s authentication REST API. There’s 3 token types of tokens:

  • Custom token (customToken): This is an auth token that can be verified by Firebase to authenticate a user, and can be used to login the user in the client. Can be created from the user’s UID. Expires in an hour.
  • Id Token (idToken): This is a token that is used to interact with the REST api. This is usually hidden when using Firebase Admin. Can also be used the authenticate the user. This can be acquired from requesting the user’s data using the REST api (eg. signIn_res). Expires in an hour.
  • Refresh token: This is an auth token that can be exchanged to create a new Id Token (which allows us to create a new custom token). Expires in about a year.

Cookies must be a ‘http-only’ cookie and ‘Secure’ (only in production) for security. This makes sure your servers are the only thing that can read and write your cookie.

In src/routes/signup.svelte :

import { goto } from '$app/navigation';

let username = '';
let email = '';
let password = '';
let error = '';

const signup = async () => {
    if (username.length < 4) return (error = 'username must be at least 4 characters long');
    if (!email.includes('@') || !email.includes('.')) return (error = 'invalid email');
    if (password.length < 6) return (error = 'password must be at least 6 characters long');
    error = '';
    const signUp_res = await fetch(`/api/new-user.json`, {
        method: 'POST',
        headers: new Headers({ 'Content-Type': 'application/json' }),
        credentials: 'same-origin',
        body: JSON.stringify({ email, password, username })
    });
    if (!signUp_res.ok) return (error = 'An error occured; please try again.');
    goto('/');
};
Enter fullscreen mode Exit fullscreen mode

III. Login

To login, send a POST request with the user’s email and password to ‘/api/auth.json’ .

  1. Login
  2. Create a new custom token
  3. Set the custom token and the refresh token as cookies

In the code below, the refresh token is set to expire in 30 days (=

src/routes/api/auth.json.js:

import { dev } from '$app/env';
import { auth } from '$lib/firebase-admin';

import * as cookie from 'cookie'

const key = [WEB_API_KEY]
const secure = dev ? '' : ' Secure;'

export const post = async (event) => {
    const { email, password } = await event.request.json()
    const signIn_res = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${key}`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ email, password, returnSecureToken: true })
    })
    if (!signIn_res.ok) return { status: signIn_res.status }
    const { refreshToken, localId } = await signIn_res.json()
    const customToken = await auth().createCustomToken(localId)
    return {
        status: 200,
        headers: {
            // Max-age : seconds
            'set-cookie': [
                `refreshToken=${refreshToken}; Max-Age=${60 * 60 * 24 * 30}; Path=/;${secure} HttpOnly`,
                `customToken=${customToken}; Max-Age=${60 * 55}; Path=/;${secure} HttpOnly`,
            ],
            'cache-control': 'no-store'
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

src/routes/api/login.svelte:

import { goto } from '$app/navigation';

let email = '';
let password = '';
let error = '';

const login = async () => {
    if (!email.includes('@') || !email.includes('.')) return (error = 'invalid email');
    if (password.length < 6) return (error = 'password must be at least 6 characters long');
    error = '';
    const signIn_res = await fetch(`/api/auth.json`, {
        method: 'POST',
        headers: new Headers({ 'Content-Type': 'application/json' }),
        credentials: 'same-origin',
        body: JSON.stringify({ email, password })
    });
    if (!signIn_res.ok) return (error = 'User does not exist or incorrect password');
    goto('/');
};
Enter fullscreen mode Exit fullscreen mode

I also added a few lines of code to check for obvious mistakes.

IV. Authenticating users

To authenticate a user, we will send a GET request to ‘/api/auth.json’.

  1. Verify the user’s custom token
  2. If verified, send the user’s data in the body
  3. If not, delete the the user’s refresh token

src/routes/api/auth.json.js:

export const get = async (event) => {
    let { refreshToken, customToken } = cookie.parse(event.request.headers.get('cookie') || '')
    if (!refreshToken) return return401()
    let headers = {}
    let user = {}
    try {
        if (!customToken) throw new Error()
        user = await auth().verifyIdToken(customToken)
    } catch (e) {
        return401()
    }
    return {
        status: 200,
        body: {
            user
        },
        headers
    }
}

const return401 = () => {
    return {
        status: 401,
        headers: {
            'set-cookie': `refreshToken=; Max-Age=0; Path=/;${secure} HttpOnly`,
            'cache-control': 'no-store'
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

But, this is inadequate as this won’t work when the custom token has expired. When the token has expired, auth().verifyIdToken() will throw an error.

  1. Get a new id token from the refresh token using the REST api
  2. Verify the newly acquired id token to get the user’s data
  3. Using the UID acquired from 2, create a new custom token
  4. Override the existing cookie and return the user’s data in the body

We also get a new custom token from step 1, but it will be the same unless it has expired. We send an error (=logout) if it is different because, at the moment, SvelteKit can only set 1 cookie in the load function.

src/routes/api/auth.json.js

export const get = async (event) => {
    let { refreshToken, customToken } = cookie.parse(event.request.headers.get('cookie') || '')
    if (!refreshToken) return return401()
    let headers = {}
    let user = {}
    try {
        if (!customToken) throw new Error()
        user = await auth().verifyIdToken(customToken)
    } catch (e) {
        // if token is expired, exchange refresh token for new token
        const refresh_res = await fetch(`https://identitytoolkit.googleapis.com/v1/token?key=${key}`, {
            method: 'POST',
            headers: { 'content-type': 'application/json' },
            body: JSON.stringify({ grant_type: 'refresh_token', 'refresh_token': refreshToken })
        })
        if (!refresh_res.ok) return return401()
        const tokens = await refresh_res.json()
        const idToken = tokens['id_token']
        if (tokens['refresh_token'] !== refreshToken) return return401()
        try {
            user = await auth().verifyIdToken(idToken)
            customToken = await auth().createCustomToken(user.uid)
            headers = {
                'set-cookie': [
                    `customToken=${customToken}; Max-Age=${60 * 55}; Path=/;${secure} HttpOnly;`,
                ],
                'cache-control': 'no-store'
            }
        } catch (e) {
            return401()
        }
    }
    return {
        status: 200,
        body: {
            user,
                        customToken
        },
        headers
    }
}
Enter fullscreen mode Exit fullscreen mode

V. Authorizing users

To redirect unauthenticated users in ‘/’, we can create a load function that sends a GET request to ‘/api/auth.json’. The load function is a function inside context="module" script and runs before the page renders. We also need to import and use SvelteKit’s fetch() since the usual fetch() doesn’t work as the load function runs before the page loads.

  1. Get the user’s data from ‘/api/auth.json’
  2. If unauthenticated, it will return a 401 status and redirect to ‘/login’ (make sure to add a 300 status!)
  3. Check for custom claims if necessary
  4. return the user’s data as props
<script context="module">
    export const load = async ({ fetch }) => {
        const auth_res = await fetch('/api/auth.json');
        if (!auth_res.ok) return { status: 302, redirect: '/login' };
        const auth = await auth_res.json();
        return {
            props: {
                user: auth.user
                customToken: auth.customToken
            }
        };
    };
</script>
Enter fullscreen mode Exit fullscreen mode

For the login/signup page where you only want unauthenticated users, replace if (!auth_res.ok) {} to (auth_res.ok) {}.

V. Signing out

To sign the user out, we just need to delete the cookies, which is possible setting the Max-Age to 0.

src/routes/api/signout.json.js:

import { dev } from '$app/env';

export const post = async () => {
    const secure = dev ? '' : ' Secure;'
    return {
        status: 200,
        headers: {
            'set-cookie': [
                `customToken=_; Max-Age=0; Path=/;${secure} HttpOnly`,
                `refreshToken=_; Max-Age=0; Path=/;${secure} HttpOnly`,
            ],
            'cache-control': 'no-store'
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

And you can sign out by calling this function:

const logout = async () => {
    await auth.signOut();
    await fetch(`/api/signout.json`, {
        method: 'POST',
        headers: new Headers({ 'Content-Type': 'application/json' }),
        credentials: 'same-origin'
    });
    goto('/login');
};
Enter fullscreen mode Exit fullscreen mode

Using Firestore

If you’re going to use Firestore with security rules, you’ll need to login using the custom token (customToken prop).

export let customToken = ""
import { signInWithCustomToken } from 'firebase/auth';

const initialize = async () => {
        const userCredential = await signInWithCustomToken(auth, customToken)
        // firestore stuff here
};
Enter fullscreen mode Exit fullscreen mode

If a user stays for more than an hour and the token expires, firebase will automatically renew the user’s session. This won’t be an issue as the refresh token won’t change.

Deploying to Vercel

It’s very simple to deploy to Vercel, and while other services like Netlify exists, Vercel is faster (at least where I live). Anyway, they’re both easy to use and SvelteKit supports many other platforms.

npm i @sveltejs/adapter-vercel
Enter fullscreen mode Exit fullscreen mode

Edit your svelte.config.js :

import vercel from '@sveltejs/adapter-vercel';

const config = {
    //...
    kit: {
        adapter: vercel()
    }
};
Enter fullscreen mode Exit fullscreen mode

Upload to Github and connect Vercel to your repository. Remember to add you domain to Firebase Auth (Authentication > Sign in method > Authorized domain). That should work!

Thanks for reading!

Top comments (8)

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Great tutorial! Thank you very much.
Is there any particular reason you name your endpoints using the *.json.js notation instead of newer *.js style?

Collapse
 
pilcrowonpaper profile image
pilcrowOnPaper

Nope, it's just that I didn't notice the change at the time of writing. I think this was the last project I used .json.js

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

Thank you for your explanation!
By the way I think there might be some typos

  • in part III.. Creating a new account. There should be src/routes/api/new-user.json.js: instead of src/routes/api/new-user/json.js:
  • there are two parts labeled III.

Is the final code available somewhere, please? Thank you 🙏

Collapse
 
piotrnajda3000 profile image
piotrnajda3000

What's the reason for creating a custom token instead of directly using the signInWithEmailAndPassword method? We're calling that method anyway through the REST API.

Collapse
 
srgeneroso profile image
srgeneroso

You don't recommend firebase hosting? much slower?

Collapse
 
arnard76 profile image
Arnav

I think, Firebase hosting is for client-side files. a svelte-kit app requires a node server (which is why SSR is possible). Vercel provides the server.

Firebase doesn't seem to include any server hosting services but Google Cloud (which Firebase is built upon) has Cloud Run. However, Google Cloud requires a billing account whereas Vercel doesn't 🙂

Collapse
 
srgeneroso profile image
srgeneroso

I tend to overcomplicate my project in the pursuit of finding limits. The EULA of netlify and vercel disallow commercial use so can't have any small business free webpage. I know as well that firebase logic is behind a paywall and haven't go that far for that reason, but technicly some logic can be abstracted to serverless functions, ultimatly that's my goal, to have very little logic (small business without store or simple store/api based don't need much) but... as always I have the same need that I understand never is going to be free, protected routes in a cdn, it basicly brakes the bare concept of a cdn, but I think something will arise within time.

Collapse
 
yndotdev profile image
Devyn

Excellent work! Thank you for this.