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

Add Google authentication to SvelteKit with Better Auth

By Justin Ahinon.

Last updated

Update (April 2026): This post originally used Lucia. Lucia was deprecated in 2024 and is now a learning resource, not a library. The version below uses Better Auth, which handles the same Google OAuth flow with a fraction of the code.

Adding Google authentication to a SvelteKit app is one of the fastest ways to cut sign-up friction. We'll do it with Better Auth, Postgres, and Drizzle. No hand-rolled OAuth code verifiers, no token-exchange endpoint to debug.

How OAuth works

OAuth lets a user authorise your app to use their Google identity without handing you their password. The browser redirects to Google, the user consents to the scopes you asked for, Google redirects back with a short-lived authorisation code, and your server exchanges that code for tokens it uses to fetch the user's profile.

Better Auth handles every step of that exchange for you. The flow is still worth knowing when you're debugging a redirect_uri_mismatch, but you won't write the plumbing yourself.

Why Better Auth and not Lucia

Lucia's author pitches the project as a guide for rolling your own session code now, not a package you install. You can still follow that playbook with Arctic for the OAuth primitives, but the amount of code you end up owning is significant.

Better Auth gives you a typed auth client, a Drizzle adapter, session management, and a generated database schema. It's the closest drop-in replacement for Lucia in the SvelteKit ecosystem.

Setting up the SvelteKit project

Scaffold a new SvelteKit app with the Node adapter. Better Auth runs on any adapter, but Node is the simplest starting point.

bunx sv create sveltekit-google-better-auth
cd sveltekit-google-better-auth
bun install

Setting up the database

Better Auth needs somewhere to store users, sessions, accounts, and verification tokens. We'll use Postgres with Drizzle ORM. The postgres.js driver keeps the dependency footprint small.

bun add drizzle-orm postgres
bun add -D drizzle-kit

Add a DATABASE_URL pointing at a local Postgres database:

echo "DATABASE_URL=postgresql://localhost:5432/sveltekit_better_auth" >> .env

Then create the Drizzle client:

// src/lib/server/db/client.ts

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { DATABASE_URL } from '$env/static/private';

if (!DATABASE_URL) throw new Error('DATABASE_URL is not set');

const client = postgres(DATABASE_URL);
const db = drizzle(client);

export { db };

And a minimal drizzle-kit config at the project root. Drizzle runs outside SvelteKit, so it reads process.env directly:

// drizzle.config.ts

import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');

export default defineConfig({
	schema: './src/lib/server/db/schema.ts',
	dbCredentials: { url: process.env.DATABASE_URL },
	dialect: 'postgresql',
	verbose: true,
	strict: true
});

Add migration scripts to package.json so you can generate and apply schema changes later:

{
	"scripts": {
		"db:generate": "drizzle-kit generate",
		"db:migrate": "drizzle-kit migrate",
		"db:push": "drizzle-kit push"
	}
}

The schema file itself doesn't exist yet. Better Auth's CLI will generate it for us in a few steps.

Google Cloud project

Google needs to know which app is asking for OAuth access. Create a new project in the Google Cloud Console. The name only matters to you.

This is the page Google shows to your users when they sign in. Go to APIs & Services → OAuth consent screen, pick External, and add these scopes:

  • openid

  • https://www.googleapis.com/auth/userinfo.email

  • https://www.googleapis.com/auth/userinfo.profile

While the app is unverified, Google only lets you log in with the email on the project and with test users you explicitly list. Add any extra email you want to sign in with under Test users.

OAuth client ID

Under Credentials → Create credentials → OAuth client ID, pick Web application.

Set Authorized JavaScript origins to http://localhost:5173 (and your production URL when you deploy).

Set Authorized redirect URIs to http://localhost:5173/api/auth/callback/google. Better Auth mounts its callback at /api/auth/callback/{provider}. This path is fixed. Getting it wrong is the most common cause of redirect_uri_mismatch errors on sign-in.

Copy the client ID and secret into .env:

echo "GOOGLE_CLIENT_ID=your-client-id" >> .env
echo "GOOGLE_CLIENT_SECRET=your-client-secret" >> .env
echo "BETTER_AUTH_URL=http://localhost:5173" >> .env

Setting up Better Auth

Install the package:

bun add better-auth

Generate a signing secret and add it to .env:

echo "BETTER_AUTH_SECRET=$(bunx @better-auth/cli secret)" >> .env

Create the server config. This is where Better Auth wires together the Drizzle adapter, the Google provider, and the SvelteKit cookie helpers:

// src/lib/server/auth.ts

import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import {
	BETTER_AUTH_SECRET,
	BETTER_AUTH_URL,
	GOOGLE_CLIENT_ID,
	GOOGLE_CLIENT_SECRET
} from '$env/static/private';
import { db } from '$lib/server/db/client';

const auth = betterAuth({
	baseURL: BETTER_AUTH_URL,
	secret: BETTER_AUTH_SECRET,
	database: drizzleAdapter(db, { provider: 'pg' }),
	socialProviders: {
		google: {
			clientId: GOOGLE_CLIENT_ID,
			clientSecret: GOOGLE_CLIENT_SECRET
		}
	},
	plugins: [sveltekitCookies(getRequestEvent)]
});

export { auth };

Two details worth calling out. sveltekitCookies must be the last plugin in the array. It depends on everything before it to set cookies correctly from server actions. And provider in the Drizzle adapter is pg for Postgres, not postgresql.

Then the client, which the Svelte components will use to kick off sign-in and sign-out:

// src/lib/auth-client.ts

import { createAuthClient } from 'better-auth/svelte';

const authClient = createAuthClient();

export { authClient };

Generating the database schema

Better Auth ships a CLI that inspects your config and emits the Drizzle schema for the tables it needs (user, session, account, verification):

bunx @better-auth/cli generate --output src/lib/server/db/schema.ts

Then generate and apply the SQL migration. Better Auth's own migrate command only works with the built-in Kysely adapter. For Drizzle, go through drizzle-kit:

bun run db:generate
bun run db:migrate

Hooks

On every request, we want to resolve the current session and make it available as event.locals. Better Auth ships svelteKitHandler to handle the /api/auth/* routes (including the Google callback), but it doesn't populate locals for you, so you resolve the session yourself:

// src/hooks.server.ts

import type { Handle } from '@sveltejs/kit';
import { building } from '$app/environment';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { auth } from '$lib/server/auth';

export const handle: Handle = async ({ event, resolve }) => {
	const session = await auth.api.getSession({ headers: event.request.headers });
	event.locals.session = session?.session ?? null;
	event.locals.user = session?.user ?? null;

	return svelteKitHandler({ event, resolve, auth, building });
};

Add the types in src/app.d.ts so TypeScript knows what's on locals:

// src/app.d.ts

import type { auth } from '$lib/server/auth';

declare global {
	namespace App {
		interface Locals {
			user: typeof auth.$Infer.Session.user | null;
			session: typeof auth.$Infer.Session.session | null;
		}
	}
}

export {};

The login page

The entire sign-in flow is one function call. authClient.signIn.social redirects the browser to Google, and once the callback returns, Better Auth creates the user and session and redirects to callbackURL.

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

<script lang="ts">
	import { authClient } from '$lib/auth-client';

	const signInWithGoogle = async () => {
		await authClient.signIn.social({
			provider: 'google',
			callbackURL: '/profile'
		});
	};
</script>

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

		<button
			onclick={signInWithGoogle}
			class="bg-gray-800 text-white font-bold rounded-md text-sm hover:bg-gray-700 px-4 py-2"
		>
			Continue with Google
		</button>
	</div>
</div>

The profile route

Guard the page on the server by checking locals.user. The hook already resolved it for us.

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

import { redirect } from '@sveltejs/kit';

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

	return { user: locals.user };
};

Then render the user, and wire up sign-out from the same component:

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

<script lang="ts">
	import { authClient } from '$lib/auth-client';
	import { goto } from '$app/navigation';

	let { data } = $props();

	const signOut = async () => {
		await authClient.signOut();
		goto('/login');
	};
</script>

<svelte:head>
	<title>{data.user.name}'s profile</title>
</svelte:head>

<div class="max-w-5xl mx-auto px-4 py-12 flex flex-col gap-4">
	<h1 class="text-2xl font-bold">Hello {data.user.name}</h1>
	<p>You are logged in as {data.user.email}.</p>

	{#if data.user.image}
		<img src={data.user.image} alt="{data.user.name}'s profile picture" class="w-20 h-20 rounded-full" />
	{/if}

	<button onclick={signOut} class="self-start bg-gray-800 text-white rounded-md px-4 py-2">
		Sign out
	</button>
</div>

Start the dev server, hit /login, and you should bounce through Google and land on /profile with your name, email, and avatar.

Wrapping up

Better Auth takes a feature that used to require a couple hundred lines of code (state cookies, PKCE verifiers, token exchange, userinfo fetches, session cookies) and reduces it to a config file and a button. The tradeoff is that you're accepting one library's opinions about schema and session management. For most SvelteKit apps, that's a very good trade.

If you want to offer email and password alongside Google, the next step is SvelteKit email and password authentication. Same library, same database tables, a few more fields on the sign-up form.

Happy coding!