Authentication
Complete guide to BrewHoard's authentication system, session management, and security practices.
BrewHoard implements a robust authentication system supporting session-based auth for browsers and API keys for programmatic access.
Authentication Overview
Session-Based Authentication
Registration Flow
// src/routes/api/v1/auth/register/+server.js
import { json } from '@sveltejs/kit';
import bcrypt from 'bcrypt';
import sql from '$lib/server/db.js';
import { createSession } from '$lib/auth/session.js';
export async function POST({ request, cookies }) {
const { email, password, displayName } = await request.json();
// 1. Validate input
if (!email || !password) {
return json({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'Email and password required' }
}, { status: 400 });
}
// 2. Check for existing user
const [existing] = await sql`
SELECT id FROM users WHERE email = ${email.toLowerCase()}
`;
if (existing) {
return json({
success: false,
error: { code: 'EMAIL_EXISTS', message: 'Email already registered' }
}, { status: 409 });
}
// 3. Hash password (bcrypt with cost factor 12)
const passwordHash = await bcrypt.hash(password, 12);
// 4. Create user
const [user] = await sql`
INSERT INTO users (email, password_hash, display_name)
VALUES (${email.toLowerCase()}, ${passwordHash}, ${displayName})
RETURNING id, email, display_name
`;
// 5. Create session
const session = await createSession(user.id, request);
// 6. Set session cookie
cookies.set('session', session.token, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days
});
return json({ success: true, data: { user } });
}Login Flow
// src/routes/api/v1/auth/login/+server.js
export async function POST({ request, cookies }) {
const { email, password } = await request.json();
// 1. Find user
const [user] = await sql`
SELECT id, email, password_hash, display_name
FROM users
WHERE email = ${email.toLowerCase()}
`;
if (!user) {
return json({
success: false,
error: { code: 'INVALID_CREDENTIALS', message: 'Invalid email or password' }
}, { status: 401 });
}
// 2. Verify password
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
return json({
success: false,
error: { code: 'INVALID_CREDENTIALS', message: 'Invalid email or password' }
}, { status: 401 });
}
// 3. Create session
const session = await createSession(user.id, request);
// 4. Set cookie
cookies.set('session', session.token, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
});
return json({
success: true,
data: {
user: { id: user.id, email: user.email, displayName: user.display_name }
}
});
}Session Validation (Server Hook)
The session is validated on every request via hooks.server.js:
// src/hooks.server.js
import sql from '$lib/server/db.js';
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
const sessionToken = event.cookies.get('session');
if (sessionToken) {
// Validate session
const [session] = await sql`
SELECT s.*, u.id as user_id, u.email, u.display_name, u.is_admin
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token = ${sessionToken}
AND s.expires_at > NOW()
`;
if (session) {
// Set user in locals (available in all routes)
event.locals.user = {
id: session.user_id,
email: session.email,
displayName: session.display_name,
isAdmin: session.is_admin
};
// Extend session if more than 1 day old
const sessionAge = Date.now() - new Date(session.created_at).getTime();
if (sessionAge > 24 * 60 * 60 * 1000) {
await sql`
UPDATE sessions
SET expires_at = NOW() + INTERVAL '7 days'
WHERE id = ${session.id}
`;
}
}
}
return resolve(event);
}Session Management
// src/lib/auth/session.js
import crypto from 'crypto';
import sql from '$lib/server/db.js';
/**
* Create a new session for a user
* @param {string} userId
* @param {Request} request
*/
export async function createSession(userId, request) {
const token = crypto.randomBytes(32).toString('hex');
const userAgent = request.headers.get('user-agent');
const ipAddress = request.headers.get('x-forwarded-for')?.split(',')[0];
const [session] = await sql`
INSERT INTO sessions (user_id, token, user_agent, ip_address, expires_at)
VALUES (
${userId},
${token},
${userAgent},
${ipAddress}::inet,
NOW() + INTERVAL '7 days'
)
RETURNING *
`;
return session;
}
/**
* Invalidate a session
* @param {string} token
*/
export async function destroySession(token) {
await sql`DELETE FROM sessions WHERE token = ${token}`;
}
/**
* Invalidate all sessions for a user
* @param {string} userId
*/
export async function destroyAllSessions(userId) {
await sql`DELETE FROM sessions WHERE user_id = ${userId}`;
}API Key Authentication
API keys provide programmatic access with scoped permissions.
Creating API Keys
// src/routes/api/v1/auth/api-keys/+server.js
import crypto from 'crypto';
import bcrypt from 'bcrypt';
export async function POST({ request, locals }) {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const { name, scopes, expiresAt } = await request.json();
// Generate API key
const prefix = process.env.NODE_ENV === 'production' ? 'bv_live_' : 'bv_test_';
const secret = crypto.randomBytes(24).toString('base64url');
const fullKey = `${prefix}${secret}`;
// Hash for storage (we only store the hash)
const keyHash = await bcrypt.hash(fullKey, 10);
const [apiKey] = await sql`
INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, expires_at)
VALUES (
${locals.user.id},
${name},
${keyHash},
${prefix},
${scopes || []},
${expiresAt}
)
RETURNING id, name, key_prefix, scopes, expires_at, created_at
`;
// Return the full key ONCE - it cannot be retrieved later
return json({
success: true,
data: {
...apiKey,
key: fullKey // Only returned on creation!
}
});
}Validating API Keys
// src/lib/middleware/auth.js
import bcrypt from 'bcrypt';
/**
* Authenticate request via API key or session
* @type {import('@sveltejs/kit').Handle}
*/
export async function authHook({ event, resolve }) {
const authHeader = event.request.headers.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
const apiKey = authHeader.slice(7);
// Find matching API key by prefix
const prefix = apiKey.slice(0, 8);
const potentialKeys = await sql`
SELECT * FROM api_keys
WHERE key_prefix = ${prefix}
AND (expires_at IS NULL OR expires_at > NOW())
`;
// Verify key hash
for (const key of potentialKeys) {
const valid = await bcrypt.compare(apiKey, key.key_hash);
if (valid) {
// Update last used timestamp
await sql`
UPDATE api_keys SET last_used_at = NOW() WHERE id = ${key.id}
`;
// Get user
const [user] = await sql`
SELECT id, email, display_name FROM users WHERE id = ${key.user_id}
`;
event.locals.user = user;
event.locals.apiKey = key;
event.locals.scopes = key.scopes;
break;
}
}
}
return resolve(event);
}API Key Scopes
Available permission scopes:
| Scope | Description |
|---|---|
collection:read | Read collection data |
collection:write | Modify collection |
marketplace:read | Browse listings |
marketplace:write | Create/manage listings |
ratings:read | Read ratings |
ratings:write | Submit ratings |
profile:read | Read user profile |
profile:write | Update profile |
Checking Scopes
// In API route handlers
export async function POST({ locals }) {
// Check if using API key
if (locals.apiKey) {
// Verify required scope
if (!locals.scopes.includes('collection:write')) {
return json({
error: { code: 'INSUFFICIENT_SCOPE', message: 'Requires collection:write scope' }
}, { status: 403 });
}
}
// ... handle request
}Security Best Practices
Password Requirements
// src/lib/auth/validation.js
/**
* Validate password strength
* @param {string} password
* @returns {{ valid: boolean, errors: string[] }}
*/
export function validatePassword(password) {
const errors = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain an uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain a lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain a number');
}
return {
valid: errors.length === 0,
errors
};
}Rate Limiting
// src/lib/middleware/rate-limit.js
const rateLimits = new Map();
/**
* Rate limit middleware
* @type {import('@sveltejs/kit').Handle}
*/
export async function rateLimitHook({ event, resolve }) {
const ip = event.request.headers.get('x-forwarded-for')?.split(',')[0]
|| 'unknown';
const path = event.url.pathname;
// Define limits by path pattern
const limits = {
'/api/v1/auth': { max: 10, window: 60 }, // 10 per minute
'/api/v1': { max: 100, window: 60 }, // 100 per minute
default: { max: 200, window: 60 } // 200 per minute
};
const limit = Object.entries(limits)
.find(([pattern]) => path.startsWith(pattern))?.[1]
|| limits.default;
const key = `${ip}:${path}`;
const now = Date.now();
const windowStart = now - (limit.window * 1000);
// Get request history
let history = rateLimits.get(key) || [];
history = history.filter(time => time > windowStart);
if (history.length >= limit.max) {
return new Response(JSON.stringify({
error: { code: 'RATE_LIMITED', message: 'Too many requests' }
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(limit.window)
}
});
}
history.push(now);
rateLimits.set(key, history);
return resolve(event);
}CSRF Protection
SvelteKit provides built-in CSRF protection for form submissions. For API routes:
// Validate origin header
export async function POST({ request }) {
const origin = request.headers.get('origin');
const host = request.headers.get('host');
// Ensure request comes from same origin
if (origin && !origin.includes(host)) {
return json({ error: 'Invalid origin' }, { status: 403 });
}
// ... handle request
}Secure Cookie Settings
cookies.set('session', token, {
path: '/',
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only (in production)
sameSite: 'lax', // CSRF protection
maxAge: 604800 // 7 days in seconds
});Protected Routes
Server-Side Protection
// src/routes/(app)/+layout.server.js
import { redirect } from '@sveltejs/kit';
export function load({ locals }) {
if (!locals.user) {
throw redirect(303, '/login');
}
return {
user: locals.user
};
}Admin-Only Routes
// src/routes/(app)/admin/+layout.server.js
import { error } from '@sveltejs/kit';
export function load({ locals }) {
if (!locals.user?.isAdmin) {
throw error(403, 'Admin access required');
}
return {};
}Logout
// src/routes/api/v1/auth/logout/+server.js
import { destroySession } from '$lib/auth/session.js';
export async function POST({ cookies }) {
const token = cookies.get('session');
if (token) {
await destroySession(token);
cookies.delete('session', { path: '/' });
}
return json({ success: true });
}Password Reset Flow
// Request reset
export async function POST({ request }) {
const { email } = await request.json();
const [user] = await sql`SELECT id FROM users WHERE email = ${email}`;
if (user) {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
await sql`
INSERT INTO password_resets (user_id, token, expires_at)
VALUES (${user.id}, ${token}, ${expiresAt})
`;
await sendPasswordResetEmail(email, token);
}
// Always return success (don't reveal if email exists)
return json({ success: true });
}Next Steps
- Sessions - Advanced session management
- API Keys - API key best practices
- API Reference - Full API documentation
Account Deletion (GDPR Right to Erasure)
Users can permanently delete their account and all associated data from the Profile page. This implements GDPR Article 17 (Right to Erasure).
User Flow
- User navigates to Profile page
- Scrolls to “Danger Zone” section
- Clicks “Delete Account”
- Confirms by typing “DELETE” in the dialog
- Account and all data are permanently removed
- User is redirected to the home page
Cascading Data Deletion
When an account is deleted, the following data is permanently removed in a single transaction:
// src/lib/auth/users.js
export async function deleteUserAccount(userId) {
await sql.begin(async (tx) => {
// Authentication & sessions
await tx`DELETE FROM sessions WHERE user_id = ${userId}`;
await tx`DELETE FROM api_keys WHERE user_id = ${userId}`;
// Notifications
await tx`DELETE FROM notifications WHERE user_id = ${userId}`;
await tx`DELETE FROM notification_preferences WHERE user_id = ${userId}`;
// User preferences
await tx`DELETE FROM user_feature_preferences WHERE user_id = ${userId}`;
// Social connections (both directions)
await tx`DELETE FROM follows WHERE follower_id = ${userId} OR following_id = ${userId}`;
// Marketplace activity
await tx`DELETE FROM transaction_ratings WHERE rater_id = ${userId}`;
await tx`DELETE FROM marketplace_transactions WHERE buyer_id = ${userId} OR seller_id = ${userId}`;
await tx`DELETE FROM marketplace_listings WHERE user_id = ${userId}`;
// Collection data
await tx`DELETE FROM beer_ratings WHERE user_id = ${userId}`;
await tx`DELETE FROM collection_items WHERE user_id = ${userId}`;
// Finally, the user record
await tx`DELETE FROM users WHERE id = ${userId}`;
});
}Server Action
The deletion is handled via a SvelteKit form action:
// src/routes/(app)/profile/+page.server.js
export const actions = {
deleteAccount: async ({ locals, cookies }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
// Delete all user data
await deleteUserAccount(locals.user.id);
// Clear session cookie
cookies.delete('session', { path: '/' });
// Redirect to home
throw redirect(303, '/?deleted=true');
}
};UI Component
The Profile page includes a confirmation dialog requiring the user to type “DELETE”:
<Dialog.Root bind:open={showDeleteDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title class="text-destructive">
<AlertTriangle class="h-5 w-5" />
Delete Account
</Dialog.Title>
<Dialog.Description>
This action cannot be undone. All your data will be permanently deleted.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4 py-4">
<Label for="deleteConfirm">Type DELETE to confirm</Label>
<Input id="deleteConfirm" bind:value={deleteConfirmText} />
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => showDeleteDialog = false}>
Cancel
</Button>
<form method="POST" action="?/deleteAccount">
<Button
type="submit"
variant="destructive"
disabled={deleteConfirmText !== 'DELETE'}
>
Yes, delete my account
</Button>
</form>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>Data Retention Policy
- Immediate deletion: All personal data is deleted immediately upon request
- No soft delete: Data is hard-deleted, not marked as inactive
- Transaction atomicity: All deletions occur in a single database transaction
- Anonymization exception: Aggregated, anonymized analytics data may be retained as it cannot be linked to individuals