Authentication System
This document describes the authentication architecture in Vanilla Cookbook, including session management, password requirements, rate limiting, OAuth integration, and known security considerations.
Architecture Overview
Vanilla Cookbook uses Lucia v2 with the Prisma adapter for session-based authentication. The system provides:
- Session-based authentication with secure cookies
- Password authentication with Argon2id hashing (Lucia default)
- Optional OAuth providers (GitHub, Google)
- Generic OIDC support for custom identity providers (Authentik, Keycloak, etc.)
- Configurable rate limiting for login attempts
- Configurable password strength requirements
Key Components
| Component | Location | Purpose |
|---|---|---|
| Lucia config | src/lib/server/lucia.js |
Auth instance with session config |
| Prisma adapter | @lucia-auth/adapter-prisma |
Database integration |
| Hooks | src/hooks.server.js |
Session validation on every request |
| Auth helpers | src/lib/server/authHelpers.js |
Reusable auth utilities for API routes |
| Page auth helpers | src/lib/server/authPage.js |
Reusable auth utilities for layout/page loads |
| Security utils | src/lib/utils/security.js |
Password validation |
| Rate limiting | src/lib/server/rateLimit.js |
Login attempt throttling |
| OIDC provider | src/lib/server/oidc.js |
Generic OIDC support |
Session Flow
Request Lifecycle
1. Request arrives
↓
2. hooks.server.js handle() runs
↓
3. Lucia validates session cookie
- auth.handleRequest(event) creates auth object
- auth.validate() checks session validity
↓
4. Session/user set on event.locals
- locals.auth = auth handler
- locals.session = session object (or null)
- locals.user = user object (or null)
↓
5. Route handler accesses locals.user
- API routes: use auth helpers
- Pages: use +page.server.js load functions
↓
6. Response returned with session cookie updates
locals Object Structure
After hooks processing, event.locals contains:
{
auth: LuciaAuthHandler, // Use for session operations
session: { // null if not authenticated
sessionId: string,
user: UserObject
},
user: { // null if not authenticated
userId: string,
username: string,
isAdmin: boolean,
publicProfile: boolean,
publicRecipes: boolean,
units: 'metric' | 'us',
skipSmallUnits: boolean,
ingMatch: boolean,
ingOriginal: boolean,
ingExtra: boolean,
useCats: boolean,
ingSymbol: boolean,
language: string,
theme: string
},
clientIp: string, // For rate limiting
limiter: RateLimiter, // Login rate limit functions
site: { // Site-wide configuration
dbSeeded: boolean,
settings: SiteSettings,
oauth: OAuthConfig,
ai: AIConfig
}
}
Database Models
AuthUser
Primary user model with authentication and preference fields:
model AuthUser {
id String @id @unique
username String @unique
email String? @unique
isAdmin Boolean @default(false)
isRoot Boolean @default(false)
publicProfile Boolean @default(false)
publicRecipes Boolean @default(false)
units String @default("metric")
language String @default("en")
theme String @default("light")
// ... user preferences
auth_session AuthSession[]
key AuthKey[]
auth_accounts AuthAccount[]
// ... other relations
}
AuthSession
Session storage for authenticated users:
model AuthSession {
id String @id @unique
user_id String
active_expires BigInt
idle_expires BigInt
user AuthUser @relation(...)
}
AuthKey
Password storage (Argon2id hashes):
model AuthKey {
id String @id @unique
hashed_password String?
user_id String
user AuthUser @relation(...)
}
AuthAccount
OAuth account links:
model AuthAccount {
id String @id @default(uuid())
provider String
provider_user_id String
user_id String
user AuthUser @relation(...)
}
Password Requirements
Password validation is handled by src/lib/utils/security.js. Requirements are configurable via environment variables.
Default Requirements
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character
Environment Configuration
# Password Requirements (all have sensible defaults)
PASSWORD_MIN_LENGTH=8 # Minimum password length
PASSWORD_REQUIRE_UPPERCASE=true # Require A-Z
PASSWORD_REQUIRE_LOWERCASE=true # Require a-z
PASSWORD_REQUIRE_DIGIT=true # Require 0-9
PASSWORD_REQUIRE_SPECIAL=true # Require !@#$%^&*()_+-=[]{}...
API Functions
import {
validatePassword,
validatePasswords,
getPasswordRequirements,
getPasswordRequirementsDescription
} from '$lib/utils/security.js'
// Validate single password
const { isValid, message } = validatePassword('MyP@ssw0rd')
// Validate password + confirmation
const { isValid, message } = validatePasswords('MyP@ssw0rd', 'MyP@ssw0rd')
// Get current requirements (for dynamic UI)
const reqs = getPasswordRequirements()
// { minLength: 8, requireUppercase: true, ... }
// Get human-readable description
const desc = getPasswordRequirementsDescription()
// "Password must be at least 8 characters, include uppercase, ..."
Rate Limiting
Login attempts are rate-limited to prevent brute force attacks.
Configuration
RATE_LIMIT_WINDOW_MS=60000 # Window size (default: 1 minute)
RATE_LIMIT_LOGIN_PER_IP=10 # Max attempts per IP per window
RATE_LIMIT_LOGIN_PER_ID=5 # Max attempts per username per window
Usage
Rate limiting is applied in login routes:
const ipCheck = locals.limiter.loginByIp(locals.clientIp)
if (!ipCheck.ok) {
return fail(429, { message: 'Too many login attempts. Try again later.' })
}
const idCheck = locals.limiter.loginById(username)
if (!idCheck.ok) {
return fail(429, { message: 'Too many login attempts for this account.' })
}
Limitations
⚠️ Current implementation uses in-memory storage:
- Rate limit counters reset when the server restarts
- Not suitable for distributed deployments (multiple instances)
- For production with multiple instances, consider Redis-backed rate limiting
OAuth Providers
Supported Providers
- GitHub: Full OAuth flow
- Google: Full OAuth flow
- Generic OIDC: Any OpenID Connect-compliant provider (Authentik, Keycloak, etc.)
Configuration
Set in .env:
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
Generic OIDC Provider
For self-hosted identity providers that support OpenID Connect (Authentik, Keycloak, Authelia, etc.).
Configuration
Set in .env:
# Required
OIDC_ISSUER_URL=https://auth.example.com/application/o/vanilla/
OIDC_CLIENT_ID=vanilla-cookbook
OIDC_CLIENT_SECRET=your_client_secret
# Optional (all have sensible defaults)
OIDC_NAME=Authentik # Display name on login button (default: "OIDC")
OIDC_EMAIL_CLAIM=email # Claim to use for email (default: "email")
OIDC_NAME_CLAIM=preferred_username # Claim to use for username (default: "preferred_username")
OIDC_SCOPES=openid email profile # Space-separated scopes (default: "openid email profile")
How It Works
- On startup, the app discovers the provider's endpoints via
OIDC_ISSUER_URL/.well-known/openid-configuration - Login button shows "Continue with {OIDC_NAME}" on the login/register pages
- Authorization uses PKCE (Proof Key for Code Exchange) for security
- After authentication, the app exchanges the code for tokens and validates them
- User identity is matched by email (if verified) or a new account is created
- The
registrationAllowedsite setting controls whether new OIDC users can be auto-provisioned
Provider Setup Examples
Authentik:
- Create an OAuth2/OpenID Provider with
Authorization Codeflow - Set redirect URI to
https://your-cookbook.example.com/api/oauth/callback - Use the provider's issuer URL as
OIDC_ISSUER_URL
Keycloak:
- Create a new client with
Standard Flowenabled - Set redirect URI to
https://your-cookbook.example.com/api/oauth/callback - Use
https://keycloak.example.com/realms/your-realmasOIDC_ISSUER_URL
Claim Mapping
Different providers may use different claim names. Common mappings:
| Provider | Email Claim | Username Claim |
|---|---|---|
| Authentik | email |
preferred_username |
| Keycloak | email |
preferred_username |
| Azure AD | email |
preferred_username |
| Auth0 | email |
nickname |
Troubleshooting
- Discovery fails: Ensure
OIDC_ISSUER_URLresponds at/.well-known/openid-configuration - HTTP issuer in dev: HTTP issuers are automatically allowed (insecure requests enabled)
- No email on account: If the provider doesn't return a verified email, the user is created without one
- Login fails after restart: OIDC discovery is cached in memory; a server restart retries automatically
Provider Detection
OAuth availability is exposed via locals.site.oauth:
{
githubEnabled: boolean, // GitHub credentials configured
googleEnabled: boolean, // Google credentials configured
oidcEnabled: boolean, // OIDC credentials configured
oidcName: string, // Display name for OIDC provider
oauthEnabled: boolean // Any provider available
}
Auth Helper Module
The $lib/server/authHelpers.js module provides reusable authentication utilities that reduce boilerplate in API routes. These helpers throw HTTP errors or return JSON responses and should only be used in API endpoints. For page/layout redirects, use $lib/server/authPage.js instead.
Functions
requireAuth(locals)
Throws 401 if user is not authenticated. Returns user object.
import { requireAuth } from '$lib/server/authHelpers'
export async function GET({ locals }) {
const user = requireAuth(locals) // Throws 401 if not logged in
// user is guaranteed to exist here
}
requireAdmin(locals)
Throws 401 if not authenticated, 403 if not admin. Returns user object.
import { requireAdmin } from '$lib/server/authHelpers'
export async function DELETE({ locals }) {
const user = requireAdmin(locals) // Throws 401/403 if not admin
// user.isAdmin is guaranteed true here
}
requireOwnership(user, resource, options?)
Throws 403 if user doesn't own the resource. Options: { allowAdmin: true } to permit admin access.
import { requireAuth, requireOwnership } from '$lib/server/authHelpers'
export async function PUT({ locals, params }) {
const user = requireAuth(locals)
const recipe = await prisma.recipe.findUnique({ where: { uid: params.id } })
requireOwnership(user, recipe) // Throws 403 if user.userId !== recipe.userId
}
// With admin override:
requireOwnership(user, recipe, { allowAdmin: true })
requireAccessOrPublic(locals, resource)
For resources that can be public. Allows access if:
- Resource is public (
is_publicorpublicRecipes) - User is authenticated and owns the resource
- User is admin (if
allowAdmin: true)
import { requireAccessOrPublic } from '$lib/server/authHelpers'
export async function GET({ locals, params }) {
const recipe = await prisma.recipe.findUnique({ ... })
requireAccessOrPublic(locals, recipe) // Throws 403 if private + not owner
}
jsonSuccess(data, status?)
Returns a JSON response with proper headers.
import { jsonSuccess } from '$lib/server/authHelpers'
return jsonSuccess({ recipe: updatedRecipe })
return jsonSuccess({ created: newItem }, 201)
jsonError(status, message)
Returns a JSON error response.
import { jsonError } from '$lib/server/authHelpers'
return jsonError(400, 'Invalid input')
return jsonError(404, 'Recipe not found')
Migration Example
Before (28 lines):
export async function PUT({ locals, params }) {
const session = await locals.auth.validate()
const user = session?.user
if (!session || !user) {
return new Response('User not authenticated!', {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
const log = await prisma.RecipeLog.findUnique({ where: { id: params.id } })
if (log.userId !== user.userId) {
return new Response(JSON.stringify({ error: 'Not owner' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
// ... actual logic
return new Response(JSON.stringify({ updatedLog }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
}
After (8 lines):
import { requireAuth, requireOwnership, jsonSuccess } from '$lib/server/authHelpers'
export async function PUT({ locals, params }) {
const user = requireAuth(locals)
const log = await prisma.RecipeLog.findUnique({ where: { id: params.id } })
requireOwnership(user, log)
// ... actual logic
return jsonSuccess({ updatedLog })
}
Backend Auth Flow (API Routes)
Typical API route pattern:
- Validate session with
requireAuth()orrequireAdmin(). - Load the resource.
- Enforce ownership or public access with
requireOwnership()/requireAccessOrPublic(). - Return
jsonSuccess()or throwerror()for failures.
import { requireAuth, requireOwnership, jsonSuccess } from '$lib/server/authHelpers'
export async function PUT({ locals, params }) {
const user = requireAuth(locals)
const recipe = await prisma.recipe.findUnique({ where: { uid: params.id } })
requireOwnership(user, recipe)
// ... mutate
return jsonSuccess({ recipe })
}
Cookie Configuration
Development
secure: false(allows HTTP)sameSite: 'lax'
Production
secure: trueif ORIGIN uses HTTPSsecure: falseif ORIGIN uses HTTP (self-hosted HTTP setups)sameSite: 'lax'
The secure cookie flag is automatically determined by:
const isHttpOrigin = env.ORIGIN?.startsWith('http://') ?? false
const useSecureCookies = !dev && !isHttpOrigin
Security Considerations
Current Protections
✅ Argon2id password hashing - Industry-standard, resistant to GPU attacks ✅ Session-based auth - Server-side session validation ✅ Rate limiting - Prevents brute force (per IP and per username) ✅ CSRF protection - SameSite cookies + CORS headers ✅ Secure cookies in HTTPS - Automatic when ORIGIN is HTTPS ✅ Configurable password strength - Environment-based requirements
Known Vulnerabilities & Recommendations
1. In-Memory Rate Limiting
Risk: Medium Issue: Rate limits reset on server restart; not distributed-ready Recommendation: For production with multiple instances, implement Redis-backed rate limiting
2. No Email Verification
Risk: Low-Medium Issue: Users can register with any email without verification Recommendation: Add email verification flow for sensitive deployments
3. No Account Lockout
Risk: Medium Issue: No permanent lockout after many failed attempts Recommendation: Implement progressive delays or temporary lockouts
4. HTTP Allowed in Production
Risk: Medium Issue: Sessions can be sent over unencrypted connections Recommendation: Use HTTPS in production; document HTTP risks for self-hosters
5. No Two-Factor Authentication
Risk: Low (for self-hosted app) Issue: Only password-based authentication Recommendation: Consider TOTP support for security-conscious users
6. No Audit Logging
Risk: Low Issue: No logging of auth events (login, logout, failed attempts) Recommendation: Add structured logging for security events
7. Session Lifetime
Risk: Low Issue: Sessions may persist longer than ideal Recommendation: Configure appropriate session expiration in Lucia
Frontend Integration
Frontend Auth Flow
- Root
+layout.server.jsloadslocals.userand site settings for all pages. - If the database is not seeded, any non-root route redirects to the setup page (
/). - Route groups enforce access (
(private)requires auth;(public)is open). - Page/layout loads use
authPagehelpers to redirect unauthenticated users.
Route Groups
(private)/- Routes requiring authentication(public)/- Routes accessible to all users
Layout files in these groups handle redirects based on auth state.
Auth State in Pages
Access via +page.server.js load function:
export async function load({ locals }) {
const user = locals.user
if (!user) redirect(302, '/login')
return { user }
}
Or via +layout.server.js for group-wide protection.
Page Auth Helpers
For layout/page load() functions, use the helpers in src/lib/server/authPage.js to reduce boilerplate:
import { requireUser, requireUserMatch, requireAdminUser } from '$lib/server/authPage'
export const load = async ({ locals, params }) => {
const user = requireUser(locals) // Redirects to /login if not authenticated
requireUserMatch(user, params.id) // Redirects to / if mismatched
// or: const admin = requireAdminUser(locals)
}
Password Requirements in the UI
The root +layout.server.js exposes the active password requirements to the client:
passwordRequirements: getPasswordRequirements(env),
passwordRequirementsDescription: getPasswordRequirementsDescription(env)
Frontend forms pass these requirements into validatePassword() / validatePasswords() so client-side messaging matches the server policy. Use buildPasswordEnv() from src/lib/utils/security.js to convert passwordRequirements into the expected env-shape.
Seeded Setup Redirects
Until the database is seeded, the root +layout.server.js redirects any request (except /) back to the setup page. This prevents login/register pages from being used before initial configuration is complete.
Troubleshooting
"User not authenticated" errors
- Check ORIGIN matches actual URL (including protocol and port)
- Verify cookies are being set (browser dev tools)
- Check for CORS issues if using different domains
Rate limit issues
- Check RATELIMIT* environment variables
- Restart server to reset in-memory counters (temporary fix)
- Check logs for IP detection issues
OAuth not working
- Verify client ID and secret are set correctly
- Check callback URLs match OAuth app configuration
- Ensure ORIGIN is correctly configured
Password validation failing
- Check PASSWORD_* environment variables
- Verify password meets all configured requirements
- Check for special character encoding issues