Countdown to launch:

Full Stack SvelteKit, a full and comprehensive video course that will teach you how to build and deploy full stack web applications using SvelteKit. Full Stack SvelteKit
Buy Now

How to implement Google authentication in a SvelteKit application with Lucia

By Justin Ahinon.

Table of Contents

Authentication might be one of the most common features of a web application. And Google OAuth is a popular way of achieving that feature. It's also a very practical way to  authenticate users on your website .

In this article, we'll explore how to implement Google authentication in a SvelteKit application using Lucia Auth.

Understanding the OAuth Protocol and Flow

Before diving into the implementation, it's important to understand what OAuth is and how it works. OAuth (Open Authorization) is a protocol that allows an application to request access to a user's protected resources without directly handling their credentials (username and password). It's commonly used to grant third-party applications limited access to user accounts on services like Google, Facebook, and Twitter.

How OAuth Works

OAuth operates through a series of steps known as the OAuth flow. Here's a breakdown of the typical OAuth 2.0 flow:

  1. User Initiates Authentication : The user attempts to log in to your application using a third-party service, such as Google. They click on a “Login with Google” button.

  2. Redirect to Authorization Server : Your application redirects the user to the Google Authorization Server with a request for access. This request includes the application’s client ID, the scope of access being requested (e.g., user profile info), and a redirect URI to send the user back to after authorization.

  3. User Grants Permission : The Google Authorization Server presents a consent screen to the user, asking if they are willing to grant your application the requested access. The user must approve this request to proceed.

  4. Authorization Code Issued : Once the user consents, the Google Authorization Server redirects the user back to your application with an authorization code. This code is short-lived and cannot be used on its own to access resources.

  5. Exchange Authorization Code for Access Token : Your application sends the authorization code to the Google Authorization Server, along with the client secret, to request an access token. The client secret is a confidential key issued to your application when you registered it with Google.

  6. Access Token Received : If the authorization code is valid and the request is authenticated, Google returns an access token to your application. This token allows your application to access the user’s resources on their behalf.

  7. Access Protected Resources : Your application uses the access token to make API requests to Google’s resource server. The access token must be included in the authorization header of these requests.

  8. Refresh Token (Optional) : In addition to the access token, your application might receive a refresh token. The refresh token can be used to obtain a new access token without requiring the user to re-authenticate when the original token expires.

Now that we have a good idea of how OAuth works, let's move on to implementing Google authentication in a SvelteKit application using Lucia Auth.

Lucia

Lucia  is an open-source, lightweight and highly customizable session-based authentication library for TypeScript applications. The library provides a set of primitives and utilities for building secure and scalable authentication systems. It's framework and database agnostic, and provides providers and adapters for popular databases like PostgreSQL, MySQL, and SQLite.

Setting up the SvelteKit project

Let's start by creating a new SvelteKit project with TypeScript and ESLint. We will also use the SvelteKit node adapter.

pnpx create-svelte@latest oauth-google-lucia
pnpm uninstall @sveltejs/adapter-auto
pnpm i @sveltejs/adapter-node -D

Let's now update the  svelte.config.js  file to use the node adapter.

// svelte.config.js

import adapter from "@sveltejs/adapter-auto";
import adapter from "@sveltejs/adapter-node";

// ...

Let's also install Tailwind CSS to make styling a bit easier and faster.

pnpx @svelte-add/tailwindcss@latest --typography false

Setting up the database

No matter the authentication method you choose, or the provider you want to use, you might need a database to store user information, or authentication metadata from the provider.

With Lucia, we'll need to store the user's email address, as well as the session information.

I like using SQLite in my projects because it's a lightweight and easy-to-use database, and for most use cases, it's very well capable and sufficient.

We will also need something to interact with the database, preferably in a type safe way.  Drizzle ORM  has been my go-to for this, and it's a great choice for TypeScript projects.

If you want a full guide on how to set up a SQLite database with Drizzle, refer to this article .

The steps are pretty straightforward:

  • We install the necessary dependencies

  • We create a client for Drizzle that connects to the database

  • We create a config file to tell Drizzle where is the database schema located, and where to run and store migrations

  • We create a database schema

  • And we apply the schema to the database

pnpm add drizzle-orm better-sqlite3 dotenv
pnpm add -D drizzle-kit @types/better-sqlite3
echo "DB_URL=src/lib/server/db/sqlite.db" >> .env
echo "src/lib/server/db/sqlite.db" >> .gitignore
// src/lib/server/db/client.ts

import Database from "better-sqlite3";
import * as dotenv from "dotenv";
import { drizzle } from "drizzle-orm/better-sqlite3";

dotenv.config();

const sqlite = new Database(process.env.DB_URL);
const db = drizzle(sqlite);

export { db, sqlite };

Now, the schema file:

// src/lib/server/db/schema.ts

import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { generateId } from "./utils";

const timestamp = {
  createdAt: integer("created_at", { mode: "number" })
    .notNull()
    .$defaultFn(() => Math.floor(Date.now() / 1000)),
  updatedAt: integer("updated_at", { mode: "number" })
    .notNull()
    .$defaultFn(() => Math.floor(Date.now() / 1000)),
};

const users = sqliteTable("users", {
  ...timestamp,
  id: text("id")
    .primaryKey()
    .notNull()
    .$defaultFn(() => generateId(15)),
  email: text("email").unique().notNull(),
  profilePicture: text("profile_picture"),
  firstName: text("first_name"),
  lastName: text("last_name"),
  refreshToken: text("refresh_token"),
});

const sessions = sqliteTable("sessions", {
  ...timestamp,
  id: text("id")
    .primaryKey()
    .notNull()
    .$defaultFn(() => generateId(15)),
  expiresAt: integer("expires_at").notNull(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id),
});

export { sessions, users };

Let's look at this schema in more detail.

For Lucia to work, we need a  users  and a  sessions  table.

Note : You can name these tables whatever you want, as long as you specify to Lucia those names. We'll see how to do that in a bit.

The required column for the  users  table is the  id  column. By default, Lucia expects this to be a  text  (string) column. But this is customizable, and  you can specify a different type for this column .

The  sessions  table needs to have an  id  (must be of type  text ),  expires_at  and a  user_id  column that references the  id  column of the  users  table.

For convenience and good practice, we'll also add timestamps to all the tables.

Also notice that I'm using the  $defaultFn  method from Drizzle to set default values for the  created_atupdated_at  and  id  columns. These default values will be set only if they are not provided when inserting a record.

The  generateId()  function is a simple function based on the  oslo/crypto  package. Here's its implementation:

import { alphabet, generateRandomString } from "oslo/crypto";

const generateId = (length: number) =>
  generateRandomString(length, alphabet("a-z", "A-Z", "0-9", "-", "_"));

Once you apply this schema to your database ( pnpm push:db ), you should be able to see your database being created at  src/lib/server/db/sqlite.db .

Sessions table for a Google authentication system using SQLite and LuciaUsers table for a Google authentication system using SQLite and Lucia

Again, take a look at this blog post for more information on how to set up a SQLite database with Drizzle.

CRUD operations

Let's create a few CRUD functions for our user's table.

// src/lib/server/db/users.ts

import { db } from "$lib/server/db/client";
import { users } from "$lib/server/db/schema";
import { eq } from "drizzle-orm";

const getUserByEmail = (email: string) => {
  return db.select().from(users).where(eq(users.email, email)).get();
};

const createNewUser = async (data: typeof users.$inferInsert) => {
  return db.insert(users).values(data).returning().get();
};

const updateUser = async (
  id: string,
  data: Partial<typeof users.$inferInsert>
) => {
  await db.update(users).set(data).where(eq(users.id, id));
};

export { createNewUser, getUserByEmail, updateUser };

Google Cloud project

To use Google OAuth, we need to create a Google Cloud project. This project will have necessary information to authenticate users with Google.

You can do this by going to the  Google Cloud Console  and creating a new project.

The OAuth consent screen

This is where you will add the information about your application and the scopes you want to request from the user. You can access the consent screen page by going to  https://console.cloud.google.com/apis/credentials/consent?project={project-id} . Make sure to add the following scopes for your application:

And also, add a test email address in case you would like to test the login process with an email address besides the one used on your Google Cloud account.

OAuth client ID

The OAuth client ID tells Google which URLs are allowed to make requests to Google servers for verifying the user's identity. You can access the client ID page by going to  https://console.cloud.google.com/apis/credentials/oauthclient?project={project-id} .

The “Authorized JavaScript origins” should be set to the URLs where your application will be accessible. In local, for SvelteKit, this is  http://localhost:5173 . You can also add other origins, like the production URL of your application or staging or testing URLs.

The “Authorized redirect URIs” should be set to the URLs where your application will be redirected after the user has authorized your application.

In local, for SvelteKit, we will use  http://localhost:5173/login/google/callback .

Once you have created your client ID, you will see a modal with the client ID and client secret. You will need to copy the client ID and client secret and update the values in the .env file.

echo "GOOGLE_OAUTH_CLIENT_ID=your-client-id" >> .env
echo "GOOGLE_OAUTH_CLIENT_SECRET=your-client-secret" >> .env
echo "GOOGLE_OAUTH_REDIRECT_URI=http://localhost:5173/login/google/callback" >> .env

Setting up Lucia

Now that we have our database set up, we can start setting up Lucia.

Here again, we need to install a few dependencies.

pnpm i lucia @lucia-auth/adapter-sqlite arctic

The  lucia  package contains the core of Lucia, and  @lucia-auth/adapter-sqlite  is used to connect Lucia to a SQLite database.

Let's create a few files to get Lucia up and running.

// src/lib/server/auth/lucia.ts

import { dev } from "$app/environment";
import { db, sqlite } from "$lib/server/db/client";
import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite";
import { Lucia } from "lucia";

interface DatabaseUserAttributes {
  email: string;
  refresh_token: string;
}

interface DatabaseSessionAttributes {
  created_at: Date;
  updated_at: Date;
}

const adapter = new BetterSqlite3Adapter(sqlite, {
  user: "users",
  session: "sessions",
});

const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      secure: !dev,
    },
  },
  getUserAttributes: (attributes) => {
    return {
      email: attributes.email,
      refresh_token: attributes.refresh_token,
    };
  },
  getSessionAttributes: (attributes) => {
    return {
      created_at: attributes.created_at,
      updated_at: attributes.updated_at,
    };
  },
});

declare module "lucia" {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: DatabaseUserAttributes;
    DatabaseSessionAttributes: DatabaseSessionAttributes;
  }
}

export { lucia };

Here is what's happening here.

First, we create the adapter that Lucia will use to interact with the database. We're using the  BetterSqlite3Adapter  which uses  better-sqlite3  under the hood. In the adapter, we specify the table names for the users and sessions.

Then, we create the Lucia instance. We pass the adapter to the constructor, and we also specify the session cookie attributes. We set the  secure  attribute to  true  in production environments, and to  false  in development environments.

That's mainly what's needed to have Lucia working.

A few more details.

When Lucia creates and validate a session, it returns session data and data about the authenticated user. By default, only the minimum required data is returned. We can customize this behavior by implementing the  getSessionAttributes  and  getUserAttributes  methods with additional data.

We also declare a “lucia” module for TypeScript to be aware of the Lucia instance and its types.

In addition to the  lucia.ts  file, we will also have a second file with the Google OAuth configuration.

// src/lib/server/auth/oauth.ts

import {
  GOOGLE_OAUTH_CLIENT_ID,
  GOOGLE_OAUTH_CLIENT_SECRET,
  GOOGLE_OAUTH_REDIRECT_URI,
} from "$env/static/private";
import { Google } from "arctic";

const scopes = [\
  "openid",\
  "https://www.googleapis.com/auth/userinfo.profile",\
  "https://www.googleapis.com/auth/userinfo.email",\
];

const google = new Google(
  GOOGLE_OAUTH_CLIENT_ID,
  GOOGLE_OAUTH_CLIENT_SECRET,
  GOOGLE_OAUTH_REDIRECT_URI
);

export { google, scopes };

We use  arctic, a TypeScript library that provides OAuth 2.0 and OpenID Connect clients for major providers.

Implementing the login flow

Let's start with the entry point, the login page.

<svelte:head>
  <title>Login with Google</title>
</svelte:head>

<div class="max-w-5xl mx-auto px-4 flex items-center justify-center h-screen">
  <div
    class="sm:w-6/12 flex p-6 border border-gray-300 shadow-md rounded-md flex-col gap-4 items-center"
  >
    <h1 class="text-2xl text-gray-700 font-bold">Login with Google</h1>

    <a
      href="/login/google"
      class="bg-gray-800 text-white font-bold rounded-md text-sm hover:bg-gray-700 px-4 py-2 flex items-center justify-center"
    >
      Login
    </a>
  </div>
</div>

When the users click on the “Login” button, the  GET  request will be sent to the  /login/google  route. This route contains a SvelteKit server endpoint, that will then redirect the user to the Google OAuth login page.

// src/routes/login/google/+server.ts

import { dev } from "$app/environment";
import { google, scopes } from "$lib/server/auth/oauth";
import { redirect } from "@sveltejs/kit";
import { generateState } from "arctic";
import { generateId } from "lucia";

export const GET = async ({ cookies }) => {
  const state = generateState();
  const codeVerifier = generateId(32);
  const url = await google.createAuthorizationURL(state, codeVerifier, {
    scopes,
  });

  url.searchParams.set("access_type", "offline");

  cookies.set("google_oauth_state", state, {
    path: "/",
    secure: !dev,
    httpOnly: true,
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  cookies.set("google_oauth_code_verifier", codeVerifier, {
    path: "/",
    secure: !dev,
    httpOnly: true,
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  redirect(302, url.toString());
};

Here, we are basically storing a state and a code verifier in the cookies. These will be needed later in the callback to make sure that those identifiers from the cookies match the ones from the OAuth response.

The callback endpoint

There are many things that need to be done in the callback endpoint.

First, we need to get the state from the cookies and check if it matches the one we sent to the Google OAuth server.

We also want to make sure that the code query parameter is actually present in the URL.

And return an error response if that's not the case.

// src/routes/login/google/callback/+server.ts

export const GET = async ({ url, cookies }) => {
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");

  const savedState = cookies.get("google_oauth_state");
  const savedCodeVerifier = cookies.get("google_oauth_code_verifier");

  if (
    !code ||
    !state ||
    !savedState ||
    !savedCodeVerifier ||
    state !== savedState
  ) {
    console.error("Invalid state or code");

    return new Response(null, {
      status: 400,
      statusText: "Bad Request",
    });
  }

  //...
};

Next, we will use the codeVerifier (saved earlier in the cookies) and the code query parameter to get access and refresh tokens from the Google OAuth server. These credentials are required by Google to make requests on the user's behalf.

These tokens will be used to make a call to the Google API at  https://openidconnect.googleapis.com/v1/userinfo  to get the user's profile information.

Side note, in this case, login acts both as a login and a registration. If the user data (retrieved from the Google API) doesn't exist in our database, we create a new user and a new session.

Otherwise, we update the user data and the session data.

Here's the whole endpoint code with that logic:

// src/routes/login/google/callback/+server.ts

import { lucia } from "$lib/server/auth/lucia";
import { google } from "$lib/server/auth/oauth";
import {
  createNewUser,
  getUserByEmail,
  updateUser,
} from "$lib/server/db/users";
import { OAuth2RequestError, type GoogleRefreshedTokens } from "arctic";

export const GET = async ({ url, cookies }) => {
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");

  const savedState = cookies.get("google_oauth_state");
  const savedCodeVerifier = cookies.get("google_oauth_code_verifier");

  if (
    !code ||
    !state ||
    !savedState ||
    !savedCodeVerifier ||
    state !== savedState
  ) {
    console.error("Invalid state or code");

    return new Response(null, {
      status: 400,
      statusText: "Bad Request",
    });
  }

  try {
    const tokens = await google.validateAuthorizationCode(
      code,
      savedCodeVerifier
    );
    let googleRefreshToken: GoogleRefreshedTokens | undefined = undefined;

    if (tokens.refreshToken) {
      googleRefreshToken = await google.refreshAccessToken(tokens.refreshToken);
    }

    const googleUserResponse = await fetch(
      "https://openidconnect.googleapis.com/v1/userinfo",
      {
        headers: {
          Authorization: `Bearer ${tokens.accessToken}`,
        },
      }
    );

    const googleUser = await googleUserResponse.json();

    const existingUser = getUserByEmail(googleUser.email);

    if (existingUser) {
      const session = await lucia.createSession(existingUser.id, {
        created_at: new Date(),
        updated_at: new Date(),
      });
      const sessionCookie = lucia.createSessionCookie(session.id);

      cookies.set(sessionCookie.name, sessionCookie.value, {
        path: ".",
        ...sessionCookie.attributes,
      });

      // Update the user's data
      await updateUser(existingUser.id, {
        refreshToken: googleRefreshToken?.accessToken,
        profilePicture: existingUser.profilePicture || googleUser.picture,
        firstName: existingUser.firstName || googleUser.given_name,
        lastName: existingUser.lastName || googleUser.family_name,
        updatedAt: Math.floor(Date.now() / 1000),
      });
    } else {
      const newUser = await createNewUser({
        email: googleUser.email,
        refreshToken: googleRefreshToken?.accessToken,
        profilePicture: googleUser.picture,
        firstName: googleUser.given_name,
        lastName: googleUser.family_name,
      });

      const session = await lucia.createSession(newUser.id, {
        created_at: new Date(),
        updated_at: new Date(),
      });
      const sessionCookie = lucia.createSessionCookie(session.id);

      cookies.set(sessionCookie.name, sessionCookie.value, {
        path: ".",
        ...sessionCookie.attributes,
      });
    }

    return new Response(null, {
      status: 302,
      headers: {
        Location: "/profile",
      },
    });
  } catch (error) {
    console.error("Error exchanging code for token", error);

    if (error instanceof OAuth2RequestError) {
      return new Response(null, {
        status: 400,
        statusText: "Bad Request",
      });
    }

    return new Response(null, {
      status: 500,
      statusText: "Internal Server Error",
    });
  }
};

When everything runs without errors, we will redirect the user to the  /profile  page, which will be protected and only be accessible to authenticated users.

Hooks

You might think that, at this point, we have a fully functional authentication system. But we still have some work to do. We want, on every request to our application, to check if the user is authenticated. And whether the user is authenticated or not, we want to return a  sessions  and  users  object.

These two objects will carry information about the current user and be available across the entire application.

To achieve that, we'll use hooks, and specifically the  handle  hook. The handle hook in SvelteKit is a function that runs before and after every request made by the application. It's used to handle things like authentication, authorization and more.

Let's create this hook in the  src/hooks.server.ts  file.

// src/hooks.server.ts

import { lucia } from "$lib/server/auth/lucia";
import type { Handle } from "@sveltejs/kit";

export const handle: Handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get(lucia.sessionCookieName);
  if (!sessionId) {
    event.locals.user = null;
    event.locals.session = null;

    return await resolve(event);
  }

  const { session, user } = await lucia.validateSession(sessionId);
  if (session && session.fresh) {
    const sessionCookie = lucia.createSessionCookie(session.id);
    event.cookies.set(sessionCookie.name, sessionCookie.value, {
      path: ".",
      ...sessionCookie.attributes,
    });
  }

  if (!session) {
    const sessionCookie = lucia.createBlankSessionCookie();
    event.cookies.set(sessionCookie.name, sessionCookie.value, {
      path: ".",
      ...sessionCookie.attributes,
    });
  }

  event.locals.user = user;
  event.locals.session = session;

  return await resolve(event);
};

Some explanations. In the callback logic before, we've created a session and a session cookie with  lucia.createSession()  and  lucia.createSessionCookie()  if the login has been successful.

Now, in our handle hooks (that, remember, will run before and after every request, whether the user is authenticated or not), we'll check if the session cookie exists in the request. If that's the case, we'll check if it's valid.

In case the session is valid, we return a user and a session objects inside SvelteKit locals. If the session is not valid, we just set blank session cookie.

Note : We can (should) add some type safety to the  locals  so that TypeScript is aware of the objects we're returning.

// src/app.d.ts
import type { Session, User } from "lucia";

declare global {
  namespace App {
    // interface Error {}
    interface Locals {
      user: User | null;
      session: Session | null;
    }
    // interface PageData {}
    // interface PageState {}
    // interface Platform {}
  }
}

export {};

The profile route

The last thing we need to do is to create a route that will show the user's profile.

Let's start with the load function in  src/routes/profile/+page.server.ts .

// src/routes/profile/+page.server.ts

import { getUserByEmail } from "$lib/server/db/users";
import { redirect } from "@sveltejs/kit";

export const load = async ({ locals }) => {
  if (!locals.session || !locals.user) {
    redirect(302, "/login");
  }

  const userProfile = getUserByEmail(locals.user.email);

  return {
    session: locals.session,
    userProfile,
  };
};

Because  /profile  is a protected route, we need to check if the user is authenticated. If the user is not authenticated, we redirect the user to the login page.

We also get and return the user profile data (from the  users  table).

And let's display these data on the page.

<!-- src/routes/profile/+page.svelte -->

<script lang="ts">
  export let data;

  const title = data.userProfile?.firstName
    ? `${data.userProfile?.firstName} profile`
    : "Profile";
</script>

<svelte:head>
  <title>{title}</title>
</svelte:head>

<div class="max-w-5xl mx-auto px-4">
  <p>Hello {data.userProfile?.firstName}</p>
  <p>You are logged in as {data.userProfile?.email}</p>
  <p>Last logged in {new Date(data.session.updated_at * 1000).toLocaleString()}</p>

  <img
    src={data.userProfile?.profilePicture}
    alt="{data.userProfile?.firstName}'s profile picture"
  />
</div>

Wrapping up

By following this guide, you now have a SvelteKit application with Google authentication using Lucia Auth.

This setup provides a secure and convenient way for users to log in with their Google accounts, leveraging the OAuth protocol and a lightweight, customizable authentication library.

This implementation covers the essentials: configuring a Google Cloud project, setting up a SQLite database with Drizzle ORM, and integrating Google OAuth into your SvelteKit app.

With these steps, your application is equipped with a robust authentication system that can be further customized to fit your needs.

Happy coding!

Stay Updated with the Full Stack SvelteKit course

Want to be the first to know when the Full Stack SvelteKit course launches? Sign up now to receive updates, exclusive content, and early access!

308 already waiting - don't miss out!