SvelteKit + Firebase: Authentication, protected routes, and persistent login
Tags: svelte, firebase, javascript, tutorial
Published: 2022-02-19 11:06:47

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](https://kit.svelte.dev/docs/introduction) and [add Tailwind CSS](https://tailwindcss.com/docs/guides/sveltekit). 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

```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qwwek39qoecd8d953jdi.png)

![/signup](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a609kt6wxa9ctcz857gf.png)


![/](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8fw4xa5uix5a31g9qlgg.png)




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`:

```jsx
npm install firebase
```

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:

```jsx
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)
```

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`.

```jsx
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
```

## 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`:

```jsx
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'
        }
    }
}
```

‘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` :

```jsx
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('/');
};
```

## 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`:

```jsx
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'
        },
    }
}
```

`src/routes/api/login.svelte`:

```jsx
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('/');
};
```

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`:

```jsx
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'
        }
    }
}
```

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`

```jsx
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
    }
}
```

## 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

```jsx
<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>
```

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`:

```jsx
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'
        },
    }
}
```

And you can sign out by calling this function:

```jsx
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');
};
```

## Using Firestore

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

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

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

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. 

```bash
npm i @sveltejs/adapter-vercel
```

Edit your `svelte.config.js` :

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

const config = {
	//...
	kit: {
		adapter: vercel()
	}
};
```

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!