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_IDandGOOGLE_CLIENT_SECRETare 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
rolefield on the user (customerdefault, orstaff/admin). - The
sveltekitCookiesplugin forwards session cookies from server-sideauth.apicalls 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:
- Creates a user via
auth.api.signUpEmail - Sets the
roletoadminand marks the email verified - 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 flagStorefront 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
| Role | Access | Customer record |
|---|---|---|
admin | Full admin panel | No |
staff | Full admin panel | No |
customer | Storefront + account | Yes (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.