Authentication
SvelteForge Admin implements authentication from scratch using SvelteKit's server infrastructure — no Lucia, no Auth.js, no external auth framework. The entire system is built on three low-level libraries:
- node:crypto — SHA-256 hashing for session tokens
- @oslojs/encoding — Base32 encoding
- @node-rs/argon2 — Argon2id password hashing (memory-hard, GPU-resistant)
By owning the auth code, you get full control over session lifetimes, cookie settings, metadata tracking, and token rotation — with zero dependency on third-party auth services. This approach leverages SvelteKit's server hooks, form actions, and server-only modules to keep sensitive logic completely off the client.
Session Management
All session logic lives in src/lib/server/auth.ts — a server-only module that SvelteKit guarantees will never leak to the client bundle. The module exports five
core functions:
generateSessionToken()
Creates a cryptographically secure session token using 20 bytes of randomness, encoded as base32 (no padding). This token is what gets stored in the user's cookie:
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
} The Web Crypto API (crypto.getRandomValues) provides the randomness — it is available
in both Node.js and all modern runtimes that SvelteKit deploys to.
hashToken()
Hashes the raw session token with SHA-256 before storing it in the database. This is a critical security measure: the raw token lives only in the user's httpOnly cookie, while the database stores only the hash. If the database is compromised, attackers cannot recover valid session tokens.
import { createHash } from "node:crypto";
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
} SHA-256 is intentionally used here instead of Argon2. Session tokens are already high-entropy random values — they do not need the slow, memory-hard hashing that passwords require. SHA-256 is fast, deterministic, and perfectly secure for this use case.
createSession()
Creates a new session in the database. The token is hashed to produce the session ID, and metadata (user agent, IP address) is stored alongside it for security auditing:
export async function createSession(
token: string,
userId: string,
metadata?: { userAgent?: string | null; ipAddress?: string | null }
): Promise<Session> {
const sessionId = hashToken(token);
const expiresAt = Date.now() + SESSION_LIFETIME_MS; // 30 days
const session: Session = {
id: sessionId,
userId,
expiresAt,
userAgent: metadata?.userAgent ?? null,
ipAddress: metadata?.ipAddress ?? null,
createdAt: new Date(),
};
await db.insert(sessions).values(session);
return session;
} Sessions have a 30-day lifetime by default, configurable via the SESSION_LIFETIME_MS constant.
validateSession()
Validates a session token by hashing it and looking up the result in the database. Handles two key scenarios:
- Expired session — Deletes the session from the database and returns
null - Session nearing expiry — If less than 15 days remain, the session is automatically extended to a fresh 30-day window (sliding expiration)
export async function validateSession(token: string): Promise<SessionValidationResult> {
const sessionId = hashToken(token);
const result = await db
.select({
session: sessions,
user: {
id: users.id,
email: users.email,
username: users.username,
name: users.name,
role: users.role,
avatarUrl: users.avatarUrl,
},
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, sessionId));
if (result.length === 0) {
return { session: null, user: null };
}
const { session, user } = result[0];
// Expired — clean up and reject
if (session.expiresAt <= Date.now()) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
return { session: null, user: null };
}
// Auto-extend if within refresh threshold (15 days)
if (session.expiresAt - Date.now() < SESSION_REFRESH_THRESHOLD_MS) {
session.expiresAt = Date.now() + SESSION_LIFETIME_MS;
await db
.update(sessions)
.set({ expiresAt: session.expiresAt })
.where(eq(sessions.id, sessionId));
}
return { session, user };
} The join with the users table returns a SessionUser object containing
only the fields needed for the UI: id, email, username, name, role, and avatarUrl. Password hashes never leave the
server.
setSessionCookie() / deleteSessionCookie()
Manage the auth_session cookie with security-hardened defaults:
export function setSessionCookie(cookies: Cookies, token: string, expiresAt: number): void {
cookies.set(SESSION_COOKIE_NAME, token, {
httpOnly: true, // Not accessible via JavaScript
sameSite: "lax", // Sent with top-level navigations
secure: !dev, // HTTPS-only in production
path: "/",
expires: new Date(expiresAt),
});
}
export function deleteSessionCookie(cookies: Cookies): void {
cookies.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
secure: !dev,
path: "/",
maxAge: 0, // Immediately expire
});
} The cookie name auth_session is exported as a constant so it can be referenced
consistently across the codebase. The secure flag is automatically disabled in
development (via SvelteKit's dev variable) to allow HTTP on localhost.
Server Hooks
SvelteKit server hooks run on every single request — page loads,
form submissions, API calls, everything. SvelteForge's hooks.server.ts is the backbone
of the auth system:
// src/hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get(SESSION_COOKIE_NAME);
if (!token) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
const { session, user } = await validateSession(token);
if (session) {
// Refresh cookie with current expiresAt (handles auto-extension)
setSessionCookie(event.cookies, token, session.expiresAt);
// Update session metadata on every request
const ua = event.request.headers.get("user-agent");
const ip = event.getClientAddress();
await db
.update(sessions)
.set({ userAgent: ua, ipAddress: ip })
.where(eq(sessions.id, session.id));
} else {
deleteSessionCookie(event.cookies);
}
event.locals.user = user;
event.locals.session = session;
return resolve(event);
}; This hook performs four operations on every request:
- Read the
auth_sessioncookie - Validate the session token (checks expiry, auto-extends if needed)
- Populate
event.locals.userandevent.locals.sessionso every server load function and form action can access the authenticated user - Update metadata — the user agent and IP address are refreshed on every request, giving you an accurate audit trail in Settings > Sessions
The App.Locals interface is typed in src/app.d.ts, ensuring TypeScript
knows about locals.user and locals.session throughout the entire SvelteKit application:
interface Locals {
user: SessionUser | null;
session: Session | null;
} Password Hashing
SvelteForge uses Argon2id via @node-rs/argon2 — the winner of the Password
Hashing Competition and the recommended algorithm for new applications. The parameters are tuned for
security:
import { hash, verify } from "@node-rs/argon2";
// Hashing (registration, password reset)
const passwordHash = await hash(password, {
memoryCost: 19456, // ~19 MB memory
timeCost: 2, // 2 iterations
outputLen: 32, // 256-bit output
parallelism: 1, // Single-threaded
});
// Verification (login, screen lock)
const valid = await verify(existingUser.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
}); These parameters ensure that each hash operation requires ~19 MB of memory and two passes, making
brute-force and GPU attacks impractical. The @node-rs/argon2 package uses native Rust bindings
for performance — significantly faster than pure JavaScript implementations.
Login Flow
The login form is a standard SvelteKit form action with progressive enhancement. The entire flow happens server-side:
- Validate inputs — username (3-31 chars) and password (6-255 chars)
- Look up user — query by lowercase username using Drizzle ORM
- Verify password — Argon2id comparison against stored hash
- Create session — generate token, hash it, store in DB with metadata
- Set cookie — httpOnly, sameSite=lax, secure in production
- Redirect — send user to the dashboard
// src/routes/(auth)/login/+page.server.ts
export const actions: Actions = {
default: async ({ request, cookies, getClientAddress }) => {
const formData = await request.formData();
const username = formData.get("username");
const password = formData.get("password");
// ... validation ...
const existingUser = await db.query.users.findFirst({
where: eq(users.username, username.toLowerCase()),
});
if (!existingUser) {
return fail(400, { message: "Incorrect username or password" });
}
const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456, timeCost: 2, outputLen: 32, parallelism: 1,
});
if (!validPassword) {
return fail(400, { message: "Incorrect username or password" });
}
const token = generateSessionToken();
const session = await createSession(token, existingUser.id, {
userAgent: request.headers.get("user-agent"),
ipAddress: getClientAddress(),
});
setSessionCookie(cookies, token, session.expiresAt);
redirect(302, "/");
},
}; Notice the deliberate use of a generic error message ("Incorrect username or password") for both missing users and wrong passwords — this prevents username enumeration attacks.
The login page uses SvelteKit's use:enhance directive for progressive
enhancement. The form works without JavaScript and upgrades seamlessly when JS is available, avoiding
full page reloads on submission.
Registration Flow
- Validate inputs — name, email, username (lowercase alphanumeric + hyphens/underscores), password
- Hash password — Argon2id with the same parameters as login
- Generate user ID — cryptographic random ID via
generateId(10) - Insert user — Drizzle ORM insert with unique constraint on email and username
- Create session — immediately log the user in
- Redirect — send to dashboard
// Registration insert (after validation and password hashing)
try {
// Serialize the first-user check and insert, including concurrent registrations.
db.transaction(
(tx) => {
tx.insert(users)
.values({
id: userId,
email: email.toLowerCase(),
username: username.toLowerCase(),
passwordHash,
name,
role: tx.select({ id: users.id }).from(users).limit(1).get() ? "viewer" : "admin",
})
.run();
},
{ behavior: "immediate" }
);
} catch {
return fail(400, { message: "Username or email already taken" });
}
First user privilege: The first registered user automatically receives the admin role. This bootstraps the application without requiring database seeding or manual
role assignment. Later registrations receive viewer access; the first-user check and insert run in an
immediate SQLite transaction.
OAuth (Google + GitHub)
Social login is implemented using the Arctic library, which provides minimal, type-safe OAuth 2.0 clients. OAuth is entirely optional — providers are configured via environment variables, and the system degrades gracefully when they are not set.
Provider Configuration
// src/lib/server/oauth.ts
import * as arctic from "arctic";
import { ORIGIN, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from "$app/env/private";
function getBaseUrl(): string {
return ORIGIN || "http://localhost:5173";
}
export const google =
GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET
? new arctic.Google(
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
`${getBaseUrl()}/login/google/callback`
)
: null;
export const github =
GITHUB_CLIENT_ID && GITHUB_CLIENT_SECRET
? new arctic.GitHub(
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
`${getBaseUrl()}/login/github/callback`
)
: null;
export function getEnabledProviders(): string[] {
const providers: string[] = [];
if (google) providers.push("google");
if (github) providers.push("github");
return providers;
} Key design decisions:
- Environment-driven: Providers are
nullwhen their env vars are missing. No errors, no crashes — just graceful absence. - Dynamic callback URLs: The
ORIGINenv var controls the base URL, so callback URLs work across localhost, staging, and production without code changes. - Conditional UI: The login page calls
getEnabledProviders()via its SvelteKit load function and only renders social login buttons for configured providers.
Login Page Integration
// src/routes/(auth)/login/+page.server.ts
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) redirect(302, "/");
return {
enabledProviders: getEnabledProviders(),
};
}; The Svelte 5 login component uses $props() to receive the enabled providers
and conditionally renders Google/GitHub buttons only when available.
OAuth Flow
The OAuth flow uses two SvelteKit server routes per provider:
- Initiation (
/login/google/+server.ts) — Generates a random state and code verifier, stores them in short-lived httpOnly cookies (10 minutes), and redirects the user to the provider's authorization URL. - Callback (
/login/google/callback/+server.ts) — Validates the state parameter against the stored cookie, exchanges the authorization code for tokens, fetches the user's profile, and either logs in an existing user or creates a new account.
The callback handler implements account linking logic:
- Check if an
oauthAccountsrecord exists for this provider + provider user ID - If yes: create a session for the linked user and redirect to dashboard
- If no: create a new user + OAuth account link + session
- If user creation fails (email conflict): attempt to link to the existing user by email
OAuth users get a random, unusable password hash — they can only authenticate via their social
provider. The user role is set to viewer by default (unlike the first registered user
who gets admin).
Password Reset Flow
Password reset follows industry best practices with hashed tokens and time-limited validity:
Step 1: Request Reset (/forgot-password)
- User enters their email address
- Server generates a 25-character random token via
generateId(25) - Token is hashed with SHA-256 and stored in the
passwordResetTokenstable with a 1-hour expiry - The reset URL is logged to the console (no email service configured in development)
- Response always returns success — never reveals whether the email exists
// Generate and store hashed token
const token = generateId(25);
const tokenHash = /* SHA-256 hash of token */;
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await db.insert(passwordResetTokens).values({
id: tokenId,
userId: user.id,
tokenHash,
expiresAt,
});
console.log(`[Password Reset] URL: /reset-password?token=${token}`); Step 2: Reset Password (/reset-password)
- User clicks the reset link containing the raw token
- Server hashes the submitted token and looks up the matching record
- Validates the token has not expired
- Hashes the new password with Argon2id
- Updates the user's password hash and deletes the used token
- Redirects to the login page
Screen Lock
The screen lock feature (/lock) allows authenticated users to lock their session and
require password re-entry to continue. This is useful for shared workstations or brief absences.
The lock page requires an active session (unauthenticated users are redirected to /login). It displays the user's name and avatar, and verifies the entered password against the stored
Argon2id hash before redirecting back to the dashboard.
Session Metadata
Every session stores the user agent and IP address, updated on every request via the server hook. This metadata powers the Settings > Sessions panel, where users can see:
- Browser name and version (parsed from the user-agent string using
src/lib/utils/user-agent.ts) - Operating system
- IP address
- Session creation time
- Last activity (based on metadata updates)
This provides a security audit trail — users can identify unfamiliar sessions and administrators can monitor access patterns.
Auth Guard
Protected routes are guarded by a single SvelteKit layout server load function in src/routes/(app)/+layout.server.ts:
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(302, "/login");
}
// Check maintenance mode
const maintenanceSetting = await db.query.appSettings.findFirst({
where: eq(appSettings.key, "maintenanceMode"),
});
if (maintenanceSetting?.value === "true" && locals.user.role !== "admin") {
error(503, "The application is currently under maintenance.");
}
return { user: locals.user, ... };
}; Because this is a layout load function, it runs before every page inside the (app) route group. No individual page needs to check authentication — it is handled once
at the layout level.
Maintenance Mode
The auth guard also enforces maintenance mode. When the maintenanceMode app setting
is set to "true", all non-admin users receive a 503 Service Unavailable error. Admin users can still access the application to manage settings and bring it back online.
This is controlled via the Settings page in the admin dashboard — toggle it on/off without any code changes or redeployment.
Database Schema
The authentication system uses four tables defined with Drizzle ORM in src/lib/server/db/schema.ts:
| Table | Purpose | Key Columns |
|---|---|---|
users | User accounts | id, email, username, passwordHash, role, avatarUrl |
sessions | Active sessions | id (hashed token), userId, expiresAt, userAgent, ipAddress |
oauthAccounts | OAuth provider links | userId, provider, providerUserId (unique index on provider
+ providerUserId) |
passwordResetTokens | Password reset requests | userId, tokenHash, expiresAt |
Roles are defined as a SQLite text enum: admin, editor, viewer. The role determines access levels throughout the application — admin users
can manage other users, change settings, and bypass maintenance mode.
Security Summary
| Measure | Implementation |
|---|---|
| Password storage | Argon2id with 19 MB memory cost, 2 iterations |
| Session tokens | 20 bytes of crypto.getRandomValues(), base32 encoded |
| Token storage | SHA-256 hash in DB; raw token only in httpOnly cookie |
| Cookie security | httpOnly, sameSite=lax, secure in production |
| Session lifetime | 30 days with sliding expiration (auto-extends at 15 days) |
| Username enumeration | Generic error messages on login; silent responses on password reset |
| Reset tokens | SHA-256 hashed, 1-hour expiry, single-use (deleted after use) |
| OAuth state | Random state parameter in httpOnly cookie, verified on callback |
| Server boundary | All auth code in #lib/server/ — SvelteKit prevents client-side
import |
| Metadata tracking | User agent and IP address updated on every request |
Need More?
Go Premium with DashboardPack
SvelteForge Admin gives you a solid Svelte 5 + SvelteKit authentication foundation. When you need enterprise-grade features — multi-tenant auth, 2FA, API key management, audit logs, and advanced RBAC — check out the premium templates at DashboardPack.
- Apex (Svelte) — SvelteKit admin with 6 dashboards, 39 pages, and a authentication screens for backend integration
- Zenith — Modern analytics dashboard with advanced data visualization
- Signal — Real-time monitoring dashboard with live data feeds
- Ember — Sleek SaaS dashboard with subscription management
- Flux — Minimalist admin with focus on speed and simplicity