Skip to main content

Security Best Practices

This guide covers security best practices for the Visita Intelligence Platform. As a civic intelligence platform handling sensitive community data, security is paramount to maintaining public trust.

Overview

Visita follows a defense-in-depth security strategy:
┌─────────────────────────────────────────┐
│  Infrastructure Security (Vercel, Supabase) │
├─────────────────────────────────────────┤
│  Application Security (Next.js, TypeScript) │
├─────────────────────────────────────────┤
│  Data Security (RLS, Encryption)        │
├─────────────────────────────────────────┤
│  Authentication & Authorization         │
├─────────────────────────────────────────┤
│  Monitoring & Incident Response         │
└─────────────────────────────────────────┘

Authentication Security

Password Policies

lib/validation/password.ts
import { z } from 'zod';

export const passwordSchema = z
  .string()
  .min(8, 'Password must be at least 8 characters')
  .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
  .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
  .regex(/[0-9]/, 'Password must contain at least one number')
  .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character');

export function checkPasswordStrength(password: string): {
  score: number;
  feedback: string[];
} {
  const feedback: string[] = [];
  let score = 0;

  if (password.length >= 8) score += 1;
  else feedback.push('Use at least 8 characters');

  if (/[A-Z]/.test(password)) score += 1;
  else feedback.push('Add uppercase letters');

  if (/[a-z]/.test(password)) score += 1;
  else feedback.push('Add lowercase letters');

  if (/[0-9]/.test(password)) score += 1;
  else feedback.push('Add numbers');

  if (/[^A-Za-z0-9]/.test(password)) score += 1;
  else feedback.push('Add special characters');

  if (password.length >= 12) score += 1;

  // Check for common passwords
  const commonPasswords = ['password', '123456', 'qwerty', 'letmein'];
  if (commonPasswords.includes(password.toLowerCase())) {
    score = 0;
    feedback.push('This password is too common');
  }

  return { score, feedback };
}

Multi-Factor Authentication (MFA)

components/auth/MFAEnrollment.tsx
'use client';

import { useState } from 'react';
import { enrollMFA, verifyMFA, generateBackupCodes } from '@/app/actions/auth';

export function MFAEnrollment() {
  const [step, setStep] = useState<'start' | 'verify' | 'backup'>('start');
  const [qrCode, setQrCode] = useState<string>('');
  const [backupCodes, setBackupCodes] = useState<string[]>([]);

  const handleEnroll = async () => {
    try {
      const { qr_code } = await enrollMFA();
      setQrCode(qr_code);
      setStep('verify');
    } catch (error) {
      console.error('Failed to enroll MFA:', error);
    }
  };

  const handleVerify = async (code: string) => {
    try {
      await verifyMFA(code);
      const { codes } = await generateBackupCodes();
      setBackupCodes(codes);
      setStep('backup');
    } catch (error) {
      console.error('Invalid verification code');
    }
  };

  return (
    <div className="max-w-md mx-auto">
      {step === 'start' && (
        <button onClick={handleEnroll} className="btn btn-primary">
          Enable Two-Factor Authentication
        </button>
      )}

      {step === 'verify' && (
        <div>
          <h3>Scan QR Code</h3>
          <img src={qrCode} alt="QR Code" />
          <input
            type="text"
            placeholder="Enter 6-digit code"
            maxLength={6}
            onChange={(e) => e.target.value.length === 6 && handleVerify(e.target.value)}
          />
        </div>
      )}

      {step === 'backup' && (
        <div>
          <h3>Backup Codes</h3>
          <p>Save these codes in a secure place:</p>
          <ul>
            {backupCodes.map((code, i) => (
              <li key={i} className="font-mono">{code}</li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

Session Security

lib/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => {
            request.cookies.set(name, value);
          });
          supabaseResponse = NextResponse.next({
            request,
          });
          cookiesToSet.forEach(({ name, value, options }) => {
            supabaseResponse.cookies.set(name, value, {
              ...options,
              httpOnly: true,
              secure: process.env.NODE_ENV === 'production',
              sameSite: 'lax',
            });
          });
        },
      },
    }
  );

  const { data: { user }, error } = await supabase.auth.getUser();

  // Protect sensitive routes
  if (request.nextUrl.pathname.startsWith('/platform') && !user) {
    const url = new URL('/auth/login', request.url);
    url.searchParams.set('return_to', request.nextUrl.pathname);
    return NextResponse.redirect(url);
  }

  // Force password reset if required
  if (user && request.nextUrl.pathname !== '/auth/update-password') {
    const { data: profile } = await supabase
      .from('profiles')
      .select('force_password_reset')
      .eq('id', user.id)
      .single();

    if (profile?.force_password_reset) {
      return NextResponse.redirect(new URL('/auth/update-password', request.url));
    }
  }

  return supabaseResponse;
}

Authorization & Access Control

Row Level Security (RLS)

supabase/migrations/20250101_rls_policies.sql
-- Enable RLS on all tables
ALTER TABLE wards ENABLE ROW LEVEL SECURITY;
ALTER TABLE signals ENABLE ROW LEVEL SECURITY;
ALTER TABLE community_projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;

-- Ward data: Readable by everyone, writable by authorized users
CREATE POLICY "Anyone can view ward data" ON wards
  FOR SELECT USING (true);

CREATE POLICY "Authorized users can update ward data" ON wards
  FOR UPDATE USING (
    auth.jwt() ->> 'role' = 'councillor' 
    OR auth.jwt() ->> 'role' = 'admin'
  );

-- Signals: Users can only see signals from their wards
CREATE POLICY "Users can view signals from their wards" ON signals
  FOR SELECT USING (
    ward_code IN (
      SELECT ward_code FROM user_wards WHERE user_id = auth.uid()
    )
  );

CREATE POLICY "Users can create signals for their wards" ON signals
  FOR INSERT WITH CHECK (
    ward_code IN (
      SELECT ward_code FROM user_wards WHERE user_id = auth.uid()
    )
    AND creator_id = auth.uid()
  );

-- Community projects: Ward residents can propose, councillors can approve
CREATE POLICY "Ward residents can view projects" ON community_projects
  FOR SELECT USING (
    ward_code IN (
      SELECT ward_code FROM user_wards WHERE user_id = auth.uid()
    )
  );

CREATE POLICY "Ward residents can propose projects" ON community_projects
  FOR INSERT WITH CHECK (
    ward_code IN (
      SELECT ward_code FROM user_wards WHERE user_id = auth.uid()
    )
    AND proposed_by = auth.uid()
    AND status = 'proposed'
  );

CREATE POLICY "Councillors can approve projects" ON community_projects
  FOR UPDATE USING (
    auth.jwt() ->> 'role' = 'councillor'
  );

-- Profiles: Users can only edit their own profile
CREATE POLICY "Users can view own profile" ON profiles
  FOR SELECT USING (auth.uid() = id);

CREATE POLICY "Users can update own profile" ON profiles
  FOR UPDATE USING (auth.uid() = id);

Role-Based Access Control

lib/auth/roles.ts
export type UserRole = 'citizen' | 'councillor' | 'admin' | 'liaison';

export interface UserPermissions {
  canViewWard: boolean;
  canSubmitSignal: boolean;
  canProposeProject: boolean;
  canApproveProject: boolean;
  canManageUsers: boolean;
  canAccessAdminPanel: boolean;
}

export function getPermissions(role: UserRole): UserPermissions {
  const permissions: Record<UserRole, UserPermissions> = {
    citizen: {
      canViewWard: true,
      canSubmitSignal: true,
      canProposeProject: true,
      canApproveProject: false,
      canManageUsers: false,
      canAccessAdminPanel: false,
    },
    councillor: {
      canViewWard: true,
      canSubmitSignal: true,
      canProposeProject: true,
      canApproveProject: true,
      canManageUsers: false,
      canAccessAdminPanel: true,
    },
    liaison: {
      canViewWard: true,
      canSubmitSignal: true,
      canProposeProject: true,
      canApproveProject: false,
      canManageUsers: false,
      canAccessAdminPanel: false,
    },
    admin: {
      canViewWard: true,
      canSubmitSignal: true,
      canProposeProject: true,
      canApproveProject: true,
      canManageUsers: true,
      canAccessAdminPanel: true,
    },
  };

  return permissions[role];
}

export function requireRole(userRole: UserRole, requiredRole: UserRole): boolean {
  const roleHierarchy: UserRole[] = ['citizen', 'liaison', 'councillor', 'admin'];
  return roleHierarchy.indexOf(userRole) >= roleHierarchy.indexOf(requiredRole);
}

Server-Side Authorization

app/actions/admin.ts
'use server';

import { createClient } from '@/lib/supabase/server';
import { getPermissions, requireRole } from '@/lib/auth/roles';

export async function approveProject(projectId: string) {
  const supabase = await createClient();
  
  // Get user
  const { data: { user }, error: userError } = await supabase.auth.getUser();
  if (!user) {
    throw new Error('Unauthorized');
  }

  // Get user's role
  const { data: profile } = await supabase
    .from('profiles')
    .select('role')
    .eq('id', user.id)
    .single();

  if (!profile || !requireRole(profile.role, 'councillor')) {
    throw new Error('Insufficient permissions');
  }

  // Check if user is councillor for this ward
  const { data: project } = await supabase
    .from('community_projects')
    .select('ward_code')
    .eq('id', projectId)
    .single();

  const { data: userWard } = await supabase
    .from('user_wards')
    .select('ward_code')
    .eq('user_id', user.id)
    .eq('ward_code', project.ward_code)
    .single();

  if (!userWard) {
    throw new Error('Not authorized for this ward');
  }

  // Approve project
  const { error } = await supabase
    .from('community_projects')
    .update({ status: 'approved', approved_by: user.id, approved_at: new Date() })
    .eq('id', projectId);

  if (error) throw error;

  return { success: true };
}

Input Validation & Sanitization

Server-Side Validation

lib/validation/schemas.ts
import { z } from 'zod';
import DOMPurify from 'isomorphic-dompurify';

// Ward code validation
export const wardCodeSchema = z
  .string()
  .regex(/^[A-Z0-9-]+$/, 'Invalid ward code format')
  .min(3, 'Ward code too short')
  .max(20, 'Ward code too long');

// Signal submission validation
export const signalSchema = z.object({
  title: z
    .string()
    .min(5, 'Title must be at least 5 characters')
    .max(200, 'Title must be less than 200 characters')
    .transform((val) => DOMPurify.sanitize(val)),
  
  description: z
    .string()
    .min(20, 'Description must be at least 20 characters')
    .max(5000, 'Description must be less than 5000 characters')
    .transform((val) => DOMPurify.sanitize(val)),
  
  category: z.enum(['safety', 'infrastructure', 'environment', 'other']),
  
  location: z.object({
    lat: z.number().min(-90).max(90),
    lng: z.number().min(-180).max(180),
  }),
  
  urgency: z.enum(['low', 'medium', 'high']),
  wardCode: wardCodeSchema,
});

// File upload validation
export const fileUploadSchema = z.object({
  file: z
    .instanceof(File)
    .refine((file) => file.size <= 5 * 1024 * 1024, 'File size must be less than 5MB')
    .refine(
      (file) => ['application/pdf', 'image/jpeg', 'image/png'].includes(file.type),
      'Only PDF, JPEG, and PNG files are allowed'
    ),
  
  description: z
    .string()
    .max(500, 'Description must be less than 500 characters')
    .optional()
    .transform((val) => val ? DOMPurify.sanitize(val) : val),
});

Rate Limiting

lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// Different rate limits for different actions
export const signalSubmissionLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 submissions per minute
  analytics: true,
});

export const contactFormLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(3, '10 m'), // 3 submissions per 10 minutes
  analytics: true,
});

export const apiLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 requests per minute
  analytics: true,
});

export async function checkRateLimit(
  identifier: string,
  limiter: Ratelimit
): Promise<{ success: boolean; remaining: number }> {
  const { success, limit, remaining } = await limiter.limit(identifier);
  
  return { success, remaining };
}
app/actions/signals.ts
'use server';

import { signalSubmissionLimit } from '@/lib/rate-limit';
import { createClient } from '@/lib/supabase/server';

export async function submitSignal(prevState: any, formData: FormData) {
  const supabase = await createClient();
  
  // Get user
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    return { success: false, message: 'Authentication required' };
  }

  // Check rate limit
  const { success, remaining } = await checkRateLimit(
    user.id,
    signalSubmissionLimit
  );

  if (!success) {
    return {
      success: false,
      message: `Too many submissions. Try again in a moment. (${remaining} remaining)`,
    };
  }

  // Validate and process signal
  // ...
}

Content Security Policy (CSP)

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: `
              default-src 'self';
              script-src 'self' 'unsafe-eval' 'unsafe-inline' https://va.vercel-scripts.com;
              style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
              font-src 'self' https://fonts.gstatic.com;
              img-src 'self' data: https: blob:;
              connect-src 'self' https://*.supabase.co https://*.upstash.io;
              frame-src 'self' https://www.google.com;
              media-src 'self';
              object-src 'none';
              base-uri 'self';
              form-action 'self';
              frame-ancestors 'none';
              upgrade-insecure-requests;
            `.replace(/\s+/g, ' ').trim(),
          },
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'Referrer-Policy',
            value: 'strict-origin-when-cross-origin',
          },
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=(self)',
          },
        ],
      },
    ];
  },
};

export default nextConfig;

Data Encryption

At Rest Encryption

supabase/migrations/20250101_encryption.sql
-- Enable pgcrypto extension
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Create encryption key (store securely in vault)
-- This should be done once and the key stored in a secure vault
-- SELECT gen_random_uuid(); -- Use this to generate a key

-- Function to encrypt sensitive data
CREATE OR REPLACE FUNCTION encrypt_sensitive(value TEXT, key TEXT)
RETURNS BYTEA AS $$
BEGIN
  RETURN pgp_sym_encrypt(value, key);
END;
$$ LANGUAGE plpgsql;

-- Function to decrypt sensitive data
CREATE OR REPLACE FUNCTION decrypt_sensitive(value BYTEA, key TEXT)
RETURNS TEXT AS $$
BEGIN
  RETURN pgp_sym_decrypt(value, key);
END;
$$ LANGUAGE plpgsql;

-- Example: Encrypt PII in profiles table
ALTER TABLE profiles ADD COLUMN email_encrypted BYTEA;
ALTER TABLE profiles ADD COLUMN phone_encrypted BYTEA;

-- Update trigger to encrypt on insert/update
CREATE OR REPLACE FUNCTION encrypt_profile_pii()
RETURNS TRIGGER AS $$
BEGIN
  IF NEW.email IS NOT NULL THEN
    NEW.email_encrypted := encrypt_sensitive(NEW.email, current_setting('app.encryption_key'));
    NEW.email := NULL; -- Don't store plain text
  END IF;
  
  IF NEW.phone IS NOT NULL THEN
    NEW.phone_encrypted := encrypt_sensitive(NEW.phone, current_setting('app.encryption_key'));
    NEW.phone := NULL; -- Don't store plain text
  END IF;
  
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_encrypt_profile_pii
  BEFORE INSERT OR UPDATE ON profiles
  FOR EACH ROW
  EXECUTE FUNCTION encrypt_profile_pii();

In Transit Encryption

lib/supabase/client.ts
import { createClient } from '@supabase/supabase-js';

// Always use HTTPS in production
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseKey, {
  auth: {
    persistSession: true,
    autoRefreshToken: true,
    detectSessionInUrl: true,
    flowType: 'pkce', // Use PKCE for additional security
  },
  global: {
    fetch: (...args) => {
      // Ensure all requests use HTTPS
      const [url, options] = args;
      if (typeof url === 'string' && !url.startsWith('https://')) {
        throw new Error('Insecure request blocked');
      }
      return fetch(...args);
    },
  },
});

Audit Logging

Database Audit Trail

supabase/migrations/20250101_audit_log.sql
-- Create audit log table
CREATE TABLE audit_log (
  id BIGSERIAL PRIMARY KEY,
  table_name TEXT NOT NULL,
  operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
  record_id UUID,
  old_values JSONB,
  new_values JSONB,
  user_id UUID REFERENCES auth.users(id),
  ip_address INET,
  user_agent TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Create index for performance
CREATE INDEX idx_audit_log_table ON audit_log(table_name);
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at);

-- Generic audit trigger function
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    INSERT INTO audit_log (
      table_name,
      operation,
      record_id,
      new_values,
      user_id
    ) VALUES (
      TG_TABLE_NAME,
      'INSERT',
      NEW.id,
      row_to_json(NEW),
      current_setting('app.user_id', true)::UUID
    );
    RETURN NEW;
  ELSIF TG_OP = 'UPDATE' THEN
    INSERT INTO audit_log (
      table_name,
      operation,
      record_id,
      old_values,
      new_values,
      user_id
    ) VALUES (
      TG_TABLE_NAME,
      'UPDATE',
      NEW.id,
      row_to_json(OLD),
      row_to_json(NEW),
      current_setting('app.user_id', true)::UUID
    );
    RETURN NEW;
  ELSIF TG_OP = 'DELETE' THEN
    INSERT INTO audit_log (
      table_name,
      operation,
      record_id,
      old_values,
      user_id
    ) VALUES (
      TG_TABLE_NAME,
      'DELETE',
      OLD.id,
      row_to_json(OLD),
      current_setting('app.user_id', true)::UUID
    );
    RETURN OLD;
  END IF;
  RETURN NULL;
END;
$$ LANGUAGE plpgsql;

-- Apply audit trigger to sensitive tables
CREATE TRIGGER audit_signals
  AFTER INSERT OR UPDATE OR DELETE ON signals
  FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();

CREATE TRIGGER audit_projects
  AFTER INSERT OR UPDATE OR DELETE ON community_projects
  FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();

CREATE TRIGGER audit_profiles
  AFTER INSERT OR UPDATE OR DELETE ON profiles
  FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();

Application-Level Logging

lib/audit.ts
import { createClient } from '@/lib/supabase/server';

export async function logSecurityEvent(
  event: string,
  details: Record<string, any>,
  userId?: string
) {
  const supabase = await createClient();
  
  try {
    await supabase.from('security_events').insert({
      event_type: event,
      details: JSON.stringify(details),
      user_id: userId,
      ip_address: details.ipAddress,
      user_agent: details.userAgent,
      severity: details.severity || 'info',
    });
  } catch (error) {
    console.error('Failed to log security event:', error);
    // Fallback to console logging
    console.log('SECURITY EVENT:', event, details);
  }
}

export async function logFailedLogin(email: string, ipAddress: string) {
  await logSecurityEvent('login_failed', {
    email,
    ipAddress,
    severity: 'warning',
  });
}

export async function logSuccessfulLogin(userId: string, ipAddress: string) {
  await logSecurityEvent('login_success', {
    userId,
    ipAddress,
    severity: 'info',
  });
}

export async function logSuspiciousActivity(
  event: string,
  details: Record<string, any>,
  userId?: string
) {
  await logSecurityEvent(event, {
    ...details,
    severity: 'high',
  }, userId);
  
  // Optionally send alert to security team
  // await sendSecurityAlert(event, details);
}

Security Headers

Middleware Security

middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Add security headers
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)');

  // HSTS in production
  if (process.env.NODE_ENV === 'production') {
    response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  }

  // Rate limiting
  const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown';
  const identifier = `middleware:${ip}`;
  
  // Simple in-memory rate limiting for middleware
  // For production, use Redis-based rate limiting
  const rateLimitKey = `rate_limit:${identifier}`;
  const rateLimitCount = parseInt(await redis.get(rateLimitKey) || '0');
  
  if (rateLimitCount > 100) {
    return new NextResponse('Too Many Requests', { status: 429 });
  }
  
  await redis.incr(rateLimitKey);
  await redis.expire(rateLimitKey, 60); // Reset every minute

  return response;
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

API Security

Webhook Security

app/api/webhooks/paystack/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(request: NextRequest) {
  try {
    const body = await request.text();
    const signature = request.headers.get('x-paystack-signature');
    
    if (!signature) {
      return new NextResponse('Unauthorized', { status: 401 });
    }

    // Verify webhook signature
    const expectedSignature = crypto
      .createHmac('sha512', process.env.PAYSTACK_SECRET_KEY!)
      .update(body)
      .digest('hex');

    if (signature !== expectedSignature) {
      return new NextResponse('Invalid signature', { status: 401 });
    }

    const payload = JSON.parse(body);
    
    // Process webhook
    // ...

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Webhook error:', error);
    return new NextResponse('Internal Server Error', { status: 500 });
  }
}

API Key Authentication

lib/api-auth.ts
import { NextRequest } from 'next/server';

export function verifyApiKey(request: NextRequest): boolean {
  const apiKey = request.headers.get('x-api-key');
  
  if (!apiKey) {
    return false;
  }

  // Hash the provided API key
  const hashedKey = crypto
    .createHash('sha256')
    .update(apiKey)
    .digest('hex');

  // Compare with stored hashes
  const validKeys = process.env.API_KEYS?.split(',') || [];
  
  return validKeys.includes(hashedKey);
}

export function requireApiKey(handler: (request: NextRequest) => Promise<Response>) {
  return async (request: NextRequest) => {
    if (!verifyApiKey(request)) {
      return new Response('Unauthorized', { status: 401 });
    }
    
    return handler(request);
  };
}

Security Testing

Security Headers Test

__tests__/security/headers.test.ts
import { NextResponse } from 'next/server';
import { middleware } from '@/middleware';

describe('Security Headers', () => {
  it('adds security headers to responses', async () => {
    const request = new NextRequest('http://localhost:3000');
    
    const response = await middleware(request);
    
    expect(response.headers.get('X-Frame-Options')).toBe('DENY');
    expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
    expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
  });

  it('adds HSTS header in production', async () => {
    process.env.NODE_ENV = 'production';
    
    const request = new NextRequest('http://localhost:3000');
    const response = await middleware(request);
    
    expect(response.headers.get('Strict-Transport-Security')).toContain('max-age=31536000');
  });
});

Input Validation Tests

__tests__/security/validation.test.ts
import { signalSchema, fileUploadSchema } from '@/lib/validation/schemas';

describe('Input Validation', () => {
  describe('signalSchema', () => {
    it('rejects XSS attempts', () => {
      const maliciousInput = {
        title: '<script>alert("XSS")</script>',
        description: 'Test description',
        category: 'safety',
        location: { lat: -26.2041, lng: 28.0473 },
        urgency: 'low',
        wardCode: 'WARD-123',
      };

      const result = signalSchema.safeParse(maliciousInput);
      
      expect(result.success).toBe(true);
      // Check that script tags are sanitized
      expect(result.data.title).not.toContain('<script>');
    });

    it('rejects invalid ward codes', () => {
      const invalidInput = {
        title: 'Test Signal',
        description: 'Test description',
        category: 'safety',
        location: { lat: -26.2041, lng: 28.0473 },
        urgency: 'low',
        wardCode: 'invalid ward code!',
      };

      const result = signalSchema.safeParse(invalidInput);
      
      expect(result.success).toBe(false);
    });
  });

  describe('fileUploadSchema', () => {
    it('rejects files larger than 5MB', () => {
      const largeFile = new File(['x'.repeat(6 * 1024 * 1024)], 'test.pdf', {
        type: 'application/pdf',
      });

      const result = fileUploadSchema.safeParse({ file: largeFile });
      
      expect(result.success).toBe(false);
    });

    it('rejects disallowed file types', () => {
      const exeFile = new File(['test'], 'malware.exe', {
        type: 'application/octet-stream',
      });

      const result = fileUploadSchema.safeParse({ file: exeFile });
      
      expect(result.success).toBe(false);
    });
  });
});

Incident Response

Security Incident Playbook

lib/incident-response.ts
export async function handleSecurityIncident(
  incident: SecurityIncident
): Promise<void> {
  console.error('SECURITY INCIDENT DETECTED:', incident);

  // 1. Immediate containment
  if (incident.type === 'account_compromise') {
    await supabase.auth.admin.updateUserById(incident.userId, {
      ban_duration: 'none',
    });
  }

  // 2. Log incident
  await logSecurityEvent('security_incident', {
    type: incident.type,
    severity: 'critical',
    details: incident.details,
    userId: incident.userId,
    ipAddress: incident.ipAddress,
  });

  // 3. Alert security team
  await sendSecurityAlert(incident);

  // 4. Preserve evidence
  await preserveEvidence(incident);

  // 5. Notify affected users if necessary
  if (incident.affectsUsers) {
    await notifyAffectedUsers(incident);
  }
}

export interface SecurityIncident {
  type: 'account_compromise' | 'data_breach' | 'suspicious_activity' | 'dos_attack';
  severity: 'low' | 'medium' | 'high' | 'critical';
  details: Record<string, any>;
  userId?: string;
  ipAddress?: string;
  affectsUsers: boolean;
}

Security Checklist

Pre-Deployment Checklist

  • All dependencies updated and scanned for vulnerabilities
  • Security headers configured correctly
  • CSP policy tested and validated
  • Rate limiting enabled for all endpoints
  • Input validation in place for all forms
  • RLS policies tested and verified
  • Audit logging enabled
  • Encryption configured for sensitive data
  • MFA enforced for admin accounts
  • Security tests passing
  • Penetration testing completed (quarterly)

Monitoring Checklist

  • Failed login attempts monitored
  • Suspicious activity alerts enabled
  • Rate limit violations tracked
  • Audit logs reviewed regularly
  • Security events dashboard active
  • Incident response plan documented
  • Security team contact information current

Status: Active
Last Updated: December 2025
Security Level: Enterprise Grade