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:
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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_at
, updated_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
.
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:
-
openid
-
userinfo.email: https://www.googleapis.com/auth/userinfo.email
-
userinfo.profile: https://www.googleapis.com/auth/userinfo.profile
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!