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

Loading diagram...

Session-Based Authentication

Registration Flow

JavaScript
// 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

JavaScript
// 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:

JavaScript
// 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

JavaScript
// 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

JavaScript
// 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

JavaScript
// 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:

ScopeDescription
collection:readRead collection data
collection:writeModify collection
marketplace:readBrowse listings
marketplace:writeCreate/manage listings
ratings:readRead ratings
ratings:writeSubmit ratings
profile:readRead user profile
profile:writeUpdate profile

Checking Scopes

JavaScript
// 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

JavaScript
// 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

JavaScript
// 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:

JavaScript
// 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

JavaScript
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

JavaScript
// 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

JavaScript
// 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

JavaScript
// 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

Loading diagram...
JavaScript
// 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

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

  1. User navigates to Profile page
  2. Scrolls to “Danger Zone” section
  3. Clicks “Delete Account”
  4. Confirms by typing “DELETE” in the dialog
  5. Account and all data are permanently removed
  6. 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:

JavaScript
// 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:

JavaScript
// 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”:

Svelte
<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