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
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:
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
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)
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
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:
- Create a new account
- Set custom claims of the user (custom claims are user data that you can add)
- Sign in as the user
- Create a custom token
- 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'
}
}
}
‘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('/');
};
III. Login
To login, send a POST request with the user’s email and password to ‘/api/auth.json’ .
- Login
- Create a new custom token
- 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'
},
}
}
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('/');
};
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’.
- Verify the user’s custom token
- If verified, send the user’s data in the body
- 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'
}
}
}
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.
- Get a new id token from the refresh token using the REST api
- Verify the newly acquired id token to get the user’s data
- Using the UID acquired from 2, create a new custom token
- 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
}
}
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.
- Get the user’s data from ‘/api/auth.json’
- If unauthenticated, it will return a 401 status and redirect to ‘/login’ (make sure to add a 300 status!)
- Check for custom claims if necessary
- 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>
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'
},
}
}
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');
};
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
};
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
Edit your svelte.config.js
:
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!
Top comments (8)
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?
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
Thank you for your explanation!
By the way I think there might be some typos
Is the final code available somewhere, please? Thank you 🙏
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.You don't recommend firebase hosting? much slower?
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 🙂
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.
Excellent work! Thank you for this.