EmpireUI
Get Pro
← Blog9 min read#sveltekit#authentication#lucia

SvelteKit Authentication: Lucia, Better Auth and Cookie Sessions

A practical guide to auth in SvelteKit — comparing Lucia v3, Better Auth, and hand-rolled cookie sessions so you can pick the right approach.

abstract security lock glowing on dark digital background

Why Auth in SvelteKit Feels Different

If you're coming from Next.js, SvelteKit's auth story will trip you up — not because it's harder, but because it works differently. The mental model is server-side first. Hooks run before routes, locals carries user state across the request, and form actions mean you can do a full auth flow without a single API route. That's a genuinely different paradigm.

Honestly, most of the confusion people run into comes from Next.js habits. You don't need an /api/auth route tree in SvelteKit. You can drop everything into +page.server.ts form actions and it just works. The framework is designed for this.

That said, the ecosystem still caught up slowly after SvelteKit hit 1.0 in late 2022. Lucia was the go-to library for a solid two years. Better Auth showed up more recently and has been eating into that space fast. And then there's the manual cookie-session approach that plenty of production apps still use. All three are valid. This article covers which one to actually reach for.

Worth noting: SvelteKit's handle hook in hooks.server.ts is the right place to attach your user to event.locals. Whatever auth library you pick, that's where it lives. Keep that pattern in your head and everything below will click.

Hand-Rolled Cookie Sessions: When You Don't Need a Library

Before reaching for any library, ask yourself one question — are you storing sessions in a database, or are you building a stateless JWT flow? Cookie sessions backed by a DB are actually not that much code. If your app has a single auth provider and a simple user table, rolling it yourself might be 200 lines and zero dependencies.

Here's a minimal hooks.server.ts that reads a signed session cookie, validates it against a Postgres table, and attaches the user to locals: ``typescript // src/hooks.server.ts import type { Handle } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { sessions } from '$lib/server/schema'; import { eq } from 'drizzle-orm'; export const handle: Handle = async ({ event, resolve }) => { const sessionId = event.cookies.get('session_id'); if (sessionId) { const session = await db.query.sessions.findFirst({ where: eq(sessions.id, sessionId), with: { user: true }, }); if (session && session.expiresAt > new Date()) { event.locals.user = session.user; } else { event.cookies.delete('session_id', { path: '/' }); } } return resolve(event); }; ``

Set the cookie on login with httpOnly: true, sameSite: 'lax', secure: true — those three attributes are non-negotiable in 2026. SvelteKit's event.cookies.set handles this cleanly. You're not fighting the browser.

In practice, this approach scales further than people expect. The session lookup is one indexed DB read. With a 15-minute sliding expiry and a Redis cache in front of Postgres, you'll handle thousands of concurrent users without blinking. The library overhead you're skipping isn't free — Lucia adds ~12KB to your server bundle on cold start.

One more thing — you need to handle CSRF. SvelteKit's form actions get CSRF protection built in via the origin check (enabled by default). If you're making fetch requests instead, add your own CSRF token. Don't skip it.

Lucia v3: The Established Choice

Lucia v3 dropped the opinionated ORM adapters it had in v1 and v2 and went database-agnostic. You write your own adapter, which is basically four functions. It sounds annoying. It's actually freeing — you get first-class Drizzle, Prisma, or raw SQL without waiting for an adapter release.

Setup with Drizzle and Postgres looks like this: ``typescript // src/lib/server/auth.ts import { Lucia } from 'lucia'; import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle'; import { db } from './db'; import { sessions, users } from './schema'; const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users); export const lucia = new Lucia(adapter, { sessionCookie: { attributes: { secure: process.env.NODE_ENV === 'production', }, }, getUserAttributes: (attributes) => ({ email: attributes.email, role: attributes.role, }), }); declare module 'lucia' { interface Register { Lucia: typeof lucia; DatabaseUserAttributes: { email: string; role: string }; } } ``

The hook stays the same pattern — lucia.validateSession(sessionId) returns a { session, user } object or nulls, and you set it on locals. Lucia v3 also handles session renewal automatically via the blank session cookie trick, so you don't have to think about sliding expiry yourself.

Quick aside: Lucia doesn't do OAuth out of the box. You pair it with the arctic package (from the same author) for Google, GitHub, Discord, etc. Arctic is tiny, well-maintained, and you'll have a working GitHub OAuth flow in about 40 lines. It's a good combo.

Look, Lucia is the right call when you want database sessions, you're comfortable writing a small adapter, and you don't need the full kitchen-sink experience. It stays out of your way. The docs are solid and the Discord is active.

Better Auth: The Newer Contender

Better Auth launched in 2024 and has been gaining momentum fast. Where Lucia makes you wire the pieces, Better Auth gives you a higher-level API — built-in OAuth plugins, email/password flows, session management, and even 2FA — all from one install. It's the auth library that leans into convention over configuration.

// src/lib/server/auth.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from './db';

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: 'pg' }),
  emailAndPassword: { enabled: true },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
});

Then in your hooks file, you call auth.handler(event) to handle the library's internal routes (like /api/auth/callback/github), and auth.api.getSession to populate locals.user. Better Auth runs its own route tree at /api/auth/** — more Next.js-like, which is either familiar or annoying depending on your mood.

The trade-off is size and magic. Better Auth's full feature set brings more dependencies, and the abstraction means debugging a subtle session issue means digging into library internals. That said, if you're adding GitHub OAuth, email verification, and password reset in a weekend, Better Auth will get you there faster than Lucia + Arctic by a significant margin.

For UI, whatever auth solution you pick, you still need forms that don't look embarrassing. Empire UI's glassmorphism components work great for auth modals — the frosted card pattern with a 64px blur radius at 0.15 opacity is a classic login page look that users recognise instantly.

Protecting Routes with SvelteKit Layouts

Auth is useless if your routes aren't protected. SvelteKit's layout load function is the right place to guard whole sections of your app. A +layout.server.ts at src/routes/(protected)/+layout.server.ts runs for every route under that group — check locals.user and redirect if it's null.

// src/routes/(protected)/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';

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

That's it. Everything under (protected)/ is now guarded. The parent layout data.user flows down to child pages via SvelteKit's data cascade, so you don't have to re-fetch the user on every route.

For role-based access, same pattern — check locals.user.role and throw a forbidden error or redirect as appropriate. If you're building an admin dashboard, consider a separate (admin)/ route group with its own layout guard. Keeps the logic clean and co-located.

One more thing — don't forget to protect your +server.ts API routes separately. Layout guards don't apply to standalone endpoints. Always check locals.user at the top of any API route that touches user data. It's one extra line and it saves you from a painful security audit later.

Form Actions for Login and Register

SvelteKit form actions are the cleanest way to build login and register. No client-side fetch, no useEffect, no state machine — just a <form method='POST'> and a server-side action. Progressive enhancement via use:enhance means it works without JavaScript too.

// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { lucia } from '$lib/server/auth';
import { db } from '$lib/server/db';
import { eq } from 'drizzle-orm';
import { users } from '$lib/server/schema';
import bcrypt from 'bcrypt';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request, cookies }) => {
    const data = await request.formData();
    const email = String(data.get('email'));
    const password = String(data.get('password'));

    const user = await db.query.users.findFirst({
      where: eq(users.email, email),
    });

    if (!user || !(await bcrypt.compare(password, user.hashedPassword))) {
      return fail(400, { message: 'Invalid credentials' });
    }

    const session = await lucia.createSession(user.id, {});
    const sessionCookie = lucia.createSessionCookie(session.id);
    cookies.set(sessionCookie.name, sessionCookie.value, {
      ...sessionCookie.attributes,
      path: '/',
    });

    redirect(302, '/dashboard');
  },
};

The fail return sends error data back to the page without a full reload. Your Svelte component reads $page.form and shows the error inline. It's a tighter loop than the old fetch-catch-setState pattern.

For the UI side of things, you'll want inputs and buttons that feel polished. The box shadow generator is a quick way to get those subtle depth effects on form cards — a 0 2px 8px shadow at 20% opacity makes a plain <input> feel considered without screaming for attention.

Honestly, the full password + session flow above is maybe 60 lines total. Compare that to what you'd write in Express with passport.js in 2018. SvelteKit's primitives are genuinely good here.

Choosing Between Lucia, Better Auth, and DIY

Here's the honest breakdown. DIY cookie sessions win if your app has one auth provider, a simple user table, and a team comfortable maintaining auth code. You ship faster in week one, and you have zero library upgrade pain in year two. A lot of SaaS apps don't need more than this.

Lucia wins when you want database sessions with a bit of structure but don't want the full opinionated layer. It's transparent — you can read the source in an afternoon. The v3 architecture is genuinely clean. Pair it with Arctic for OAuth and you've got a complete solution with predictable surface area.

Better Auth wins when speed of feature development matters more than control. If the product roadmap has 2FA, magic links, and three OAuth providers in the next sprint, Better Auth's plugin system gets you there without building all that plumbing yourself. The cost is a larger abstraction and more trust in the library's internals.

Quick aside: regardless of which you pick, spend 10 minutes on your session security settings. Rotate session IDs on privilege escalation, expire idle sessions after 30 days max, and log auth events to a table you can audit. These aren't library features — they're decisions you make.

If you're building the UI around your auth flows and want a design system that scales from a login modal to a full dashboard, Empire UI has components for all of it. The glassmorphism generator is a solid starting point for auth card designs that feel premium without custom CSS nightmares.

FAQ

Can I use Lucia v3 with SvelteKit and Prisma?

Yes — Lucia ships an official @lucia-auth/adapter-prisma package. Point it at your session and user models and you're done. The setup is nearly identical to the Drizzle example above.

Does Better Auth work with SvelteKit's edge runtime?

Partially. Better Auth's core works on the edge, but some adapters and plugins have Node.js dependencies. Check the specific adapter before deploying to Cloudflare Workers or Vercel Edge Functions.

How do I handle OAuth redirect callbacks in SvelteKit?

Create a +server.ts route at the callback URL (e.g. /auth/callback/github). Handle the code exchange there, create a session, set the cookie, and redirect. Arctic's helper functions handle the OAuth 2.0 code exchange for you.

Should I use JWTs instead of database sessions in SvelteKit?

Only if you need stateless auth across multiple services. For a standard web app, database sessions are safer — you can revoke them instantly, which JWTs can't do without a blocklist that defeats the stateless point anyway.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

SvelteKit Guide 2026: Routing, Load Functions, Forms and DeploySvelte 5 Runes: $state, $derived, $effect and the New Mental ModelBetter Auth Guide 2026: Sessions, Social, 2FA in Next.jsAuthentication in Next.js 2026: NextAuth v5, Clerk, Better Auth