Security Best Practices
This guide covers the security patterns used throughout Pixelflare. Follow these when adding features or fixing bugs to keep the codebase secure.
Core Security Principles
Pixelflare follows a defense-in-depth approach with multiple layers of security:
- Authentication - Verify who the user is
- Authorization - Check what they're allowed to do
- Input Validation - Never trust user input
- Encryption - Protect data at rest
- Rate Limiting - Prevent abuse
- Audit Logging - Track what happens
Authentication
We support multiple authentication modes configured via AUTH_MODE:
- Cloudflare Access - JWT-based Zero Trust authentication
- Auth.js - OAuth providers (GitHub, Google)
- API Keys - Bearer token authentication with scopes
- No-Auth - Development only (when
CF_ACCESS_AUD="dev")
How Authentication Works
graph LR
A[Request] --> B{Auth Header?}
B -->|Bearer token| C[API Key or JWT]
B -->|Cookie| D[Auth.js Session]
B -->|CF-Access-Jwt| E[Cloudflare Access]
C --> F[Verify & Extract Owner]
D --> F
E --> F
F --> G[c.get auth]The tryAuthenticate() middleware tries authentication strategies in order and sets c.get('auth') with the user's owner ID and email.
Adding Authentication to Routes
import { authMiddleware } from '@/middleware/auth';
// Require authentication
router.use('*', authMiddleware());
// Your handler can now access auth
router.get('/something', async (c) => {
const auth = c.get('auth');
// auth.owner - unique user identifier
// auth.email - user's email (optional)
});Authorization - The Golden Rule
Every API endpoint MUST verify ownership before accessing or modifying resources.
This is the most critical security rule in the codebase. Get this wrong and users can access each other's data.
The Pattern
Always pass auth.owner to service functions and filter database queries by owner:
// ✅ CORRECT - Verifies ownership
router.patch('/images/:id', async (c) => {
const auth = c.get('auth');
const { id } = c.req.valid('param');
const db = getDb(c.env.DB);
// This function filters by auth.owner internally
const image = await getImage(db, auth.owner, id, c.env.CDN_PUBLIC_HOST);
if (!image) {
throw new NotFoundError('Image not found');
}
// Now safe to update
await updateImage(db, auth.owner, id, updates);
});
// ❌ WRONG - No ownership check!
router.patch('/images/:id', async (c) => {
const { id } = c.req.valid('param');
const db = getDb(c.env.DB);
// User could access ANY image!
const image = await getImageById(db, id);
await updateImageUnsafe(db, id, updates);
});Service Layer Pattern
Service functions should always accept owner as the first parameter after db:
// Good pattern from services/images/queries.ts
export async function getImage(
db: Database,
owner: string, // Always verify ownership
imageId: string,
cdnHost: string
): Promise<ImageWithUrl | null> {
const result = await db
.select()
.from(images)
.where(
and(
eq(images.id, imageId),
eq(images.owner, owner) // Filter by owner!
)
)
.limit(1);
return result.length > 0 ? enrichImage(result[0], cdnHost) : null;
}Database Query Checklist
Before writing any database query, ask yourself:
- [ ] Does it filter by
owner? - [ ] Could a user access another user's data?
- [ ] Am I using a safe service function like
getImage(db, owner, ...)?
Input Validation
We use Zod schemas for all input validation, defined in /packages/api/src/schemas/.
Validation Rules
Every endpoint must validate input. The OpenAPI routes handle this automatically:
import { schemas } from '@/schemas/validation';
router.post('/images', async (c) => {
const body = c.req.valid('json'); // Already validated by OpenAPI
// Zod has already:
// - Sanitized filename (path traversal, null bytes)
// - Validated content type against allowed types
// - Checked file size limits
// - Stripped HTML tags from text fields
});Security Validation Helpers
Use functions from lib/security-validation.ts:
import {
validateFilename,
validateSlug,
sanitizeText,
validateURL,
verifyImageMagicBytes,
} from '@/lib/security-validation';
// Prevent path traversal
const safeFilename = validateFilename(userInput);
// Prevent SQL injection in slugs (defense in depth)
const safeSlug = validateSlug(albumSlug);
// Strip HTML/script tags
const safeText = sanitizeText(userDescription);
// Prevent SSRF attacks
const safeUrl = validateURL(importUrl);
// Verify file is actually an image
if (!verifyImageMagicBytes(fileBuffer, contentType)) {
throw new ValidationError('File type mismatch');
}What We Block
Defined in config/src/security.ts:
- Path traversal:
..,./,\\, encoded variants - Dangerous files:
.exe,.bat,.sh,.ps1, executables - Prototype pollution:
__proto__,constructor,prototype - Hidden files: Starting with
. - Null bytes:
\0characters - Private IPs: Localhost, 10.x, 192.168.x, 172.16-31.x
- Suspicious URLs:
file://,gopher://, etc.
Cryptography
Image Encryption (AES-256-GCM)
We use authenticated encryption with a two-tier key hierarchy defined in lib/encryption.ts:
Root Key (env var)
↓ SHA-256
TMK (Tenant Master Key) - per user, stored in KV
↓ AES-GCM
CEK (Content Encryption Key) - per image, stored with metadata
↓ AES-GCM
Encrypted ImageKey Properties:
- 256-bit keys (32 bytes)
- 96-bit IV (12 bytes)
- 128-bit authentication tag
- Additional Authenticated Data (AAD) includes owner/image IDs
API Key Hashing
API keys are hashed with HMAC-SHA256 before storage:
import { hashApiKey } from '@/lib/crypto';
// Generate key
const apiKey = generateApiKey(); // Returns "pfk_xxx..."
const hashedKey = await hashApiKey(apiKey, env.API_HASH_SECRET);
// Store only the hash
await db.insert(apiKeys).values({
key_hash: hashedKey,
// Never store plaintext key!
});
// Verification
const inputHash = await hashApiKey(inputKey, env.API_HASH_SECRET);
const match = await db.query.apiKeys.findFirst({
where: eq(apiKeys.key_hash, inputHash),
});Constant-Time Comparisons
Always use constant-time comparison for secrets to prevent timing attacks:
// In lib/encryption.ts
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
// Used for token verification
if (!constantTimeEqual(expectedToken, providedToken)) {
return null; // Invalid
}Rate Limiting
We use a token bucket algorithm with KV storage, implemented in lib/rate-limit.ts.
Adding Rate Limits
import { checkRateLimit } from '@/lib/rate-limit';
router.post('/expensive-operation', async (c) => {
const auth = c.get('auth');
// Check rate limit
const result = await checkRateLimit(
c.env.KV_CACHE,
`operation:${auth.owner}`, // Unique key
10, // 10 requests
60000 // per 60 seconds
);
if (!result.allowed) {
throw new RateLimitError('Too many requests', {
limit: result.limit,
remaining: 0,
resetAt: result.resetAt,
});
}
// Continue with operation...
});Rate Limit Strategy
- Per-user limits: Key format
ratelimit:upload:{owner} - Per-IP limits: Key format
ratelimit:cdn:{ip} - Fail-open behavior: If KV is down, requests are allowed (availability over strict limiting)
- Memory caching: Reduces KV writes by ~90%
Current limits in config/src/api.ts:
- Upload: 10 req/min (burst), varies by plan tier
- CDN custom transforms: 30 req/min per IP
- Public endpoints: 60 req/min per IP
Security Headers
Security headers are configured in config/src/headers.ts and applied by middleware/security.ts:
// API routes (strict)
Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Strict-Transport-Security: max-age=31536000; includeSubDomains
Referrer-Policy: strict-origin-when-cross-origin
// CDN routes (allows embedding)
X-Frame-Options: SAMEORIGIN
Cache-Control: public, max-age=31536000, immutableCORS Policies
Three CORS configurations in middleware/cors.ts:
- Public API - GET only, no credentials, allows Pages domains
- Authenticated API - All methods, credentials required, app host only
- Auth.js routes - Auth methods, credentials required, app host only
import { corsPublicAPI, corsAuthenticatedAPI } from '@/middleware/cors';
// Public endpoints (profile pages, public albums)
router.get('/public/*', corsPublicAPI);
// Authenticated endpoints
router.use('/v1/*', corsAuthenticatedAPI);Error Handling
Never leak sensitive information in error messages. We use custom error classes in lib/errors.ts:
import { NotFoundError, ValidationError, UnauthorizedError, ForbiddenError } from '@/lib/errors';
// ✅ GOOD - Generic message
throw new NotFoundError('Image not found');
// ❌ BAD - Leaks information
throw new Error(`Image ${id} does not exist for user ${owner}`);
throw new Error(`Database query failed: ${dbError.stack}`);Errors are handled by middleware/error-handler.ts which formats errors consistently, logs appropriately, and sanitizes sensitive data.
File Upload Security
File uploads go through several validation layers. The full upload flow is documented in Image Upload, but here are the key security checks in services/upload.ts:
- Size limit - 100MB maximum
- Content-type verification - Magic byte detection, not just headers
- Dimension limits - 1px to 50,000px
- EXIF stripping - Optional metadata removal
See the image upload documentation for the complete flow.
Audit Logging
All user actions are logged when AUDIT_LOG_ENABLED=true. Use the auditLogger middleware for automatic logging:
import { auditLogger } from '@/middleware/audit-logger';
router.delete('/images/:id', auditLogger('delete', 'image'), async (c) => {
/* ... */
});For more details on audit logging configuration and querying logs, see the Audit Log documentation.
API Keys
API keys support scope-based permissions (read, write, delete), IP allowlists, and expiration dates. The middleware in lib/router.ts handles scope enforcement:
import { requireScopes, requireScopesByMethod } from '@/lib/router';
// Require specific scope
router.delete('/images/:id', requireScopes('delete'));
// Auto-scope by HTTP method (GET=read, POST/PUT/PATCH=write, DELETE=delete)
router.use('*', requireScopesByMethod());For API key creation, management, and full documentation, see the API Reference.
Webhooks
Webhooks use HMAC-SHA256 signatures for verification, implemented in services/webhooks/signing.ts. Recipients should verify the X-Pixelflare-Signature header using constant-time comparison.
For webhook setup, event types, and signature verification examples, see the webhooks section in the API documentation.
Database Security
We use Drizzle ORM which provides parameterized queries, type safety, and schema validation.
Safe Query Pattern
import { images } from '@database';
import { eq, and } from 'drizzle-orm';
// ✅ SAFE - Parameterized query with owner filter
const result = await db
.select()
.from(images)
.where(
and(
eq(images.id, imageId), // Parameterized
eq(images.owner, auth.owner) // Owner filter
)
);
// ❌ NEVER DO THIS
// Raw SQL is disabled anyway, but don't try to bypass the ORMAll queries are automatically parameterized, making SQL injection impossible.
Testing Security
Security tests in __tests__/:
What to Test
describe('Authorization', () => {
it('prevents user A from accessing user B images', async () => {
const imageB = await createImage(db, 'userB', {...});
// Try to access as userA
const response = await request(app)
.get(`/v1/images/${imageB.id}`)
.set('Authorization', `Bearer ${userAKey}`);
expect(response.status).toBe(404); // Not 403 - don't leak existence
});
it('validates input prevents path traversal', async () => {
const response = await request(app)
.post('/v1/images')
.send({ filename: '../../../etc/passwd' });
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('enforces rate limits', async () => {
// Make 11 requests (limit is 10)
for (let i = 0; i < 11; i++) {
const res = await request(app).post('/v1/images').send({...});
if (i < 10) expect(res.status).toBe(200);
if (i === 10) expect(res.status).toBe(429);
}
});
});Security Checklist
Use this when adding new features:
- [ ] Authentication middleware applied to route
- [ ] Authorization verified (passes
auth.ownerto service functions) - [ ] Input validated with Zod schemas
- [ ] User input sanitized (filenames, text, URLs)
- [ ] Database queries filter by
owner - [ ] Sensitive data not logged
- [ ] Errors don't leak information
- [ ] Rate limiting applied (if expensive operation)
- [ ] Security headers configured
- [ ] CORS policy appropriate for endpoint
- [ ] Tests cover authorization scenarios
- [ ] No hardcoded secrets or credentials
Secret Management
Secrets are managed in Cloudflare Workers secrets (not environment variables):
# Set secrets (never commit these!)
wrangler secret put API_HASH_SECRET
wrangler secret put UPLOAD_TOKEN_SECRET
wrangler secret put ENCRYPTION_ROOT_KEY
wrangler secret put AUTH_SECRET
# Access in code
const secret = env.API_HASH_SECRET;Never:
- Commit secrets to git
- Log secrets
- Return secrets in API responses
- Store secrets in database (hash them instead)
- Use weak development secrets in production
Development secrets in wrangler.dev.toml are clearly marked as unsafe and must never be used in production.
Common Vulnerabilities We Prevent
| Vulnerability | How We Prevent It |
|---|---|
| SQL Injection | Drizzle ORM with parameterized queries |
| XSS | Zod sanitization, CSP headers, JSON responses |
| CSRF | SameSite cookies, origin validation |
| Path Traversal | Filename validation, regex blocking |
| SSRF | URL validation, private IP blocking |
| IDOR | Ownership validation on every query |
| Timing Attacks | Constant-time comparisons |
| Brute Force | Rate limiting per user/IP |
| DoS | Rate limiting, file size limits |
| Information Disclosure | Generic error messages, no stack traces |
| Session Fixation | Secure session tokens, JWT expiration |
| Clickjacking | X-Frame-Options, CSP frame-ancestors |
Security Updates
When a security issue is discovered:
- Assess severity - Is data at risk? Can users access others' data?
- Fix immediately - Don't wait for other features
- Test thoroughly - Add tests to prevent regression
- Deploy ASAP - Use
pnpm deployto push fix - Audit similar code - Check for same pattern elsewhere
- Document - Update this guide if needed
For responsible disclosure, security issues should be reported via /.well-known/security.txt.