Skip to Content
Core ConceptsAuthentication

Authentication

Hoikka uses Better Auth  for authentication — email/password, optional Google OAuth, and email OTP. Sessions are stored in the database (the same SQLite/D1 backend as the rest of the app), so auth works identically on both deploy targets.

Setup

Environment variables

BETTER_AUTH_SECRET= # signs sessions (required) BETTER_AUTH_URL= # optional — derived from the request if unset GOOGLE_CLIENT_ID= # optional — enables Google sign-in GOOGLE_CLIENT_SECRET= # (both must be set)

The Better Auth instance is built in src/lib/server/auth.ts. It’s constructed lazily behind a Proxy so build-time route analysis (where env vars are empty) doesn’t need a secret — construction is deferred to the first real request.

Configuration highlights:

  • Email + password, minimum 8 characters. Email verification is not required to sign in.
  • Google OAuth is enabled only when GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are both set.
  • Email OTP plugin for verification / password reset. In dev the OTP is logged to the console ([auth] OTP for ...) — wire Resend/SMTP for production.
  • A role field on the user (customer default, or staff / admin).
  • The sveltekitCookies plugin forwards session cookies from server-side auth.api calls into the SvelteKit response.

First-time admin setup

On first visit to /admin, if no admin user exists you are sent to /admin/setup to create the initial account. It:

  1. Creates a user via auth.api.signUpEmail
  2. Sets the role to admin and marks the email verified
  3. Signs the new admin in and redirects to /admin

Once an admin exists, the setup page redirects to /admin/login.

How it works

Auth handler

All Better Auth requests are handled by a catch-all route at /api/auth/[...all] that delegates to auth.handler.

Session validation

src/hooks.server.ts validates the session on each request. Anonymous traffic short-circuits: if no better-auth.session_token cookie is present, the DB lookup is skipped entirely.

// hooks.server.ts — sessionHandler const result = await auth.api.getSession({ headers: event.request.headers }); if (result?.user) { event.locals.user = { id: result.user.id, name: result.user.name, email: result.user.email, role: result.user.role, // "customer", "staff", or "admin" emailVerified: result.user.emailVerified }; }

Customer sync

When a non-admin user is present, a customers record is created (or found) and set on locals.customer. The authUserId column links it to the Better Auth user.id; the name is split into firstName / lastName. Admin and staff users do not get a customer record.

Locals

After hooks run, every request has:

locals.user // { id, name, email, role?, emailVerified? } | null locals.customer // Customer record | null (null for admin/staff) locals.adminDark // admin dark-theme cookie flag

Storefront auth

The client uses src/lib/auth-client.ts (createAuthClient with the email-OTP plugin).

Sign up

/sign-up — email/password or Google OAuth.

import { authClient } from "$lib/auth-client"; await authClient.signUp.email({ name, email, password }); // Google OAuth authClient.signIn.social({ provider: "google", callbackURL: "/" });

Sign in

/sign-in — email/password or Google OAuth.

const result = await authClient.signIn.email({ email, password });

Email verification

/verify-email — 6-digit OTP.

await authClient.emailOtp.sendVerificationOtp({ email, type: "email-verification" }); await authClient.emailOtp.verifyEmail({ email, otp });

Password reset

/forgot-password — two-step OTP flow.

await authClient.emailOtp.sendVerificationOtp({ email, type: "forget-password" }); await authClient.emailOtp.resetPassword({ email, otp, password });

Route protection

Storefront account routes

/account/* is protected by a layout guard (src/routes/(storefront)/account/+layout.server.ts) — requires a logged-in user with a verified email and a customer record, otherwise redirects to /sign-in.

Admin routes

/admin/* requires a role of admin or staff (login and setup pages excepted):

// src/routes/admin/+layout.server.ts if (!locals.user || !["admin", "staff"].includes(locals.user.role ?? "")) { throw redirect(303, "/admin/login"); }

Roles

RoleAccessCustomer record
adminFull admin panelNo
staffFull admin panelNo
customerStorefront + accountYes (auto-created)

The initial admin is created via /admin/setup. Additional admins or staff are promoted in the database:

UPDATE user SET role = 'staff' WHERE email = 'staff@example.com';

OAuth (Google)

Set GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET to enable Google sign-in. authClient.signIn.social({ provider: "google" }) runs the standard OAuth redirect flow through Better Auth; OAuth users have their email verified automatically.

Last updated on