Database Security
Database Security
Learn how to secure your Solo Kit database against common vulnerabilities and implement enterprise-grade security practices. This guide covers SQL injection prevention, access control, encryption, and comprehensive security monitoring.
🔒 Security Overview
Solo Kit Security Architecture
Solo Kit implements defense-in-depth security with multiple layers:
- Connection Security: SSL/TLS encryption and secure connection strings
- Authentication: BetterAuth with secure session management
- Authorization: Role-based access control (RBAC) with database-level constraints
- Input Validation: Prepared statements and type safety with Drizzle ORM
- Data Protection: Encryption at rest and in transit
- Monitoring: Comprehensive security scanning and audit logging
Security Principles
Zero Trust Database Design:
- Principle of Least Privilege: Minimal required permissions only
- Defense in Depth: Multiple security layers working together
- Secure by Default: Safe configurations out-of-the-box
- Continuous Monitoring: Real-time security health checks
🛡️ SQL Injection Prevention
Drizzle ORM Protection
Solo Kit uses Drizzle ORM which provides automatic SQL injection protection:
// ✅ SAFE: Drizzle uses prepared statements automatically
export async function getUserById(id: string): Promise<User | null> {
const database = await getRequiredDb();
const result = await database
.select()
.from(users)
.where(eq(users.id, id)) // Automatically parameterized
.limit(1);
return result[0] || null;
}
// ✅ SAFE: All Drizzle queries use prepared statements
export async function getUserByEmail(email: string): Promise<User | null> {
const database = await getRequiredDb();
const result = await database
.select()
.from(users)
.where(eq(users.email, email)) // Safe parameterized query
.limit(1);
return result[0] || null;
}
Why Drizzle is Secure:
- Automatic Parameterization: All values are automatically parameterized
- Type Safety: TypeScript prevents many injection vectors
- No String Concatenation: Values never concatenated into SQL strings
- Prepared Statements: All queries use prepared statements by default
Raw SQL Security (When Needed)
If you must use raw SQL, use parameters:
import { sql } from 'drizzle-orm';
// ✅ SAFE: Parameterized raw SQL
const result = await database.execute(
sql`SELECT * FROM users WHERE email = ${email} AND role = ${role}`
);
// ❌ DANGEROUS: Never concatenate user input
// const result = await database.execute(
// sql`SELECT * FROM users WHERE email = '${email}'`
// );
Input Validation at Schema Level
Solo Kit implements database-level validation:
// packages/database/schema/users.ts
export const users = pgTable(
'users',
{
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
email: text('email').notNull().unique(),
name: text('name').notNull(),
// ... other columns
},
(table) => ({
// Database-level email validation constraint
validEmail: check(
'valid_email',
sql`email ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'`
),
})
);
Multiple validation layers:
// packages/database/schema/verification-tokens.ts
export const verificationTokens = pgTable(
'verification_tokens',
{
// ... columns
email: text('email').notNull(),
// ... other columns
},
(_table) => ({
// Database-level email validation constraint
validEmail: check(
'valid_verification_token_email',
sql`email ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'`
),
})
);
// packages/database/schema/used-tokens.ts
export const usedTokens = pgTable(
'used_tokens',
{
// ... columns
email: text('email').notNull(),
// ... other columns
},
(_table) => ({
// Database-level email validation constraint
validEmail: check(
'valid_used_token_email',
sql`email ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'`
),
})
);
🔐 Authentication & Authorization
Role-Based Access Control
Solo Kit implements comprehensive RBAC:
// Database schema with roles
export const userRoleEnum = pgEnum('user_role', ['admin', 'user']);
export const users = pgTable('users', {
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
email: text('email').notNull().unique(),
role: userRoleEnum('role').notNull().default('user'), // Default to user
// ... other columns
});
Role-based query patterns:
// Admin-only operations
export async function adminOnly<T>(
userId: string,
operation: () => Promise<T>
): Promise<T> {
const user = await getUserById(userId);
if (!user || user.role !== 'admin') {
throw new Error('Access denied: Admin privileges required');
}
return await operation();
}
// Usage example
export async function deleteAnyUser(adminId: string, targetUserId: string) {
return await adminOnly(adminId, async () => {
const database = await getRequiredDb();
const result = await database
.delete(users)
.where(eq(users.id, targetUserId))
.returning({ id: users.id });
return result.length > 0;
});
}
Secure Session Management
Solo Kit uses BetterAuth for secure session handling:
// apps/web/lib/auth.ts
export const authOptions: BetterAuthOptions = {
// Session-based authentication (more secure than JWT-only)
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day - extends on activity
freshAge: 60 * 15, // 15 minutes for sensitive operations
storeSessionInDatabase: true, // Sessions are revocable
// Performance optimization with security
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes - short cache for security
},
},
// Enhanced cookie security
advanced: {
useSecureCookies: process.env.NODE_ENV === 'production',
cookies: {
sessionToken: {
name: 'better-auth.session_token',
attributes: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'lax', // CSRF protection
path: '/',
httpOnly: true, // Prevent XSS attacks
},
},
},
},
// Trusted origins for CORS protection
trustedOrigins: [authConfig.baseURL],
};
Security features:
- HttpOnly Cookies: Prevents XSS access to session tokens
- Secure Cookies: HTTPS-only in production
- SameSite Protection: CSRF attack prevention
- Database Sessions: Revocable sessions for security
- Session Rotation: Automatic session refresh
Password Security
Argon2 password hashing with legacy support:
// apps/web/lib/utils/password.ts
export async function hashPassword(password: string): Promise<string> {
// Use Argon2 for new passwords (industry standard)
return await hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 parallel threads
hashLength: 32, // 32 bytes output
});
}
export async function verifyLegacyPassword(
password: string,
hash: string
): Promise<{ isValid: boolean; needsRehash: boolean }> {
// Support migration from bcrypt/scrypt
if (hash.startsWith('$argon2')) {
// Already Argon2
const isValid = await verify(hash, password);
return { isValid, needsRehash: false };
} else if (hash.startsWith('$2b$')) {
// Legacy bcrypt - verify and mark for rehash
const isValid = await bcrypt.compare(password, hash);
return { isValid, needsRehash: isValid };
}
// Unknown format
return { isValid: false, needsRehash: false };
}
🔗 Connection Security
Secure Database URLs
Environment variable protection:
# .env.local (never committed)
DATABASE_URL="postgresql://user:password@host:5432/db?sslmode=require"
Connection string security features:
- SSL/TLS Required:
sslmode=require
parameter - Connection Timeouts: Prevent hanging connections
- Connection Pooling: Controlled concurrent connections
Dual-Driver Security
Solo Kit's connection architecture includes security features:
// packages/database/lib/connection.ts
export async function getDb() {
if (!databaseUrl) {
return null; // Fail securely
}
try {
if (databaseUrl.includes('neon.tech')) {
// Neon serverless - built-in security features
const client = neon(databaseUrl);
return drizzle(client);
} else {
// postgres.js with security configuration
const client = postgres(databaseUrl, {
max: 10, // Limit concurrent connections
idle_timeout: 20, // Close idle connections
connect_timeout: 30, // Prevent hanging
prepare: false, // Better compatibility
ssl: process.env.NODE_ENV === 'production' ? 'require' : false,
});
return drizzle(client);
}
} catch (error) {
console.error('Database connection failed:', error);
return null; // Fail securely
}
}
Connection String Enhancement
Drizzle configuration with security parameters:
// packages/database/drizzle.config.ts
const enhancedDatabaseUrl = () => {
const baseUrl = process.env.DATABASE_URL!;
if (!baseUrl) return baseUrl;
const url = new URL(baseUrl);
// Add security and timeout parameters
url.searchParams.set('connect_timeout', '30');
url.searchParams.set('command_timeout', '30');
url.searchParams.set('application_name', 'drizzle-kit');
url.searchParams.set('sslmode', 'require'); // Force SSL
return url.toString();
};
🧹 Data Protection & Cleanup
Automated Token Cleanup
Solo Kit includes automatic security token cleanup:
// packages/database/lib/auth-cleanup.ts
export async function cleanupExpiredTokens(): Promise<number> {
try {
const now = new Date();
const database = await db;
if (!database) {
throw new Error('Database not available');
}
// Clean expired verification tokens
const result = await database
.delete(verificationTokens)
.where(lt(verificationTokens.expiresAt, now))
.returning({ id: verificationTokens.id });
const deletedCount = result.length;
if (deletedCount > 0) {
console.info(`🧹 Cleaned up ${deletedCount} expired verification tokens`);
}
return deletedCount;
} catch (error) {
console.error('Failed to cleanup expired tokens:', error);
throw error;
}
}
// User-specific cleanup for security incidents
export async function cleanupUserTokens(
userId: string,
tokenType?: 'email_verification' | 'password_reset'
): Promise<number> {
try {
const database = await db;
if (!database) {
throw new Error('Database not available');
}
const whereCondition = tokenType
? and(
eq(verificationTokens.userId, userId),
eq(verificationTokens.type, tokenType)
)
: eq(verificationTokens.userId, userId);
const result = await database
.delete(verificationTokens)
.where(whereCondition)
.returning({ id: verificationTokens.id });
return result.length;
} catch (error) {
console.error('Failed to cleanup user tokens:', error);
throw error;
}
}
Token Replay Attack Prevention
Used token tracking for security:
// packages/database/schema/used-tokens.ts
export const usedTokens = pgTable('used_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
jti: text('jti').unique().notNull(), // JWT ID from token
tokenHash: text('token_hash').notNull(), // Hashed token for security
usedAt: timestamp('used_at', { mode: 'date' }).notNull().defaultNow(),
expiresAt: timestamp('expires_at', { mode: 'date' }).notNull(),
purpose: text('purpose').notNull(), // 'purchase-verification', etc.
email: text('email').notNull(), // For audit trail
createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(),
});
// Usage example
export async function markTokenAsUsed(
jti: string,
tokenHash: string,
purpose: string,
email: string
) {
const database = await getRequiredDb();
await database.insert(usedTokens).values({
jti,
tokenHash,
purpose,
email,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
});
}
export async function isTokenUsed(jti: string): Promise<boolean> {
const database = await getRequiredDb();
const result = await database
.select({ id: usedTokens.id })
.from(usedTokens)
.where(eq(usedTokens.jti, jti))
.limit(1);
return result.length > 0;
}
🔍 Security Monitoring
Comprehensive Security Scanning
Solo Kit includes automated security scanning:
# Run comprehensive security scan
pnpm security-scan
Security scan features (scripts/security-scan.ts):
- Dependency vulnerability scanning: pnpm audit integration
- Source code security scanning: Pattern-based vulnerability detection
- Authentication flow analysis: Session and token validation
- CORS and CSP policy validation: Header security checks
- Input sanitization checks: SQL injection prevention validation
- Rate limiting verification: DoS protection validation
- Security headers validation: HTTPS and security header checks
Database Health Monitoring
Built-in database security health checks:
// API route: app/api/health/database/route.ts
export async function GET() {
try {
const startTime = Date.now();
const database = await getDb();
if (!database) {
return Response.json(
{
status: 'unhealthy',
message: 'Database not configured',
security: 'unknown'
},
{ status: 503 }
);
}
// Test basic connectivity and security
await database.execute(sql`SELECT 1`);
const responseTime = Date.now() - startTime;
// Additional security checks
const securityStatus = await checkDatabaseSecurity(database);
return Response.json({
status: 'healthy',
responseTime: `${responseTime}ms`,
security: securityStatus,
timestamp: new Date().toISOString(),
});
} catch (error) {
return Response.json(
{
status: 'unhealthy',
error: error.message,
security: 'failed'
},
{ status: 503 }
);
}
}
async function checkDatabaseSecurity(database: any) {
try {
// Check for common security configurations
const sslResult = await database.execute(sql`SHOW ssl`);
const connectionCount = await database.execute(
sql`SELECT count(*) FROM pg_stat_activity`
);
return {
ssl: sslResult[0]?.ssl === 'on',
activeConnections: connectionCount[0]?.count,
status: 'secure'
};
} catch (error) {
return { status: 'unknown', error: error.message };
}
}
Audit Logging
Security event logging patterns:
// Security event logging
export function logSecurityEvent(
eventType: 'login' | 'logout' | 'admin_action' | 'token_cleanup',
userId?: string,
details?: Record<string, any>
) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
event: eventType,
userId: userId || 'anonymous',
details: details || {},
ip: getClientIP(), // Implement based on your setup
};
// Log to secure audit system
console.info('🔒 SECURITY EVENT:', JSON.stringify(logEntry));
// In production, also send to:
// - Security monitoring service (Sentry, DataDog, etc.)
// - Audit database table
// - SIEM system
}
// Usage examples
logSecurityEvent('login', userId, { method: 'email' });
logSecurityEvent('admin_action', adminId, { action: 'user_delete', targetUserId });
logSecurityEvent('token_cleanup', undefined, { expiredCount: 15 });
🚨 Security Best Practices
1. Environment Security
Secure environment variable management:
# ✅ GOOD: .env.local (never committed)
DATABASE_URL="postgresql://user:secure_password@db.example.com:5432/prod_db?sslmode=require"
# ✅ GOOD: .env (safe defaults, committed)
EMAIL_PROVIDER=console
PAYMENTS_PROVIDER=disabled
# ❌ NEVER: Commit real credentials to git
Environment validation:
// Validate critical security settings
function validateSecurityEnvironment() {
const errors = [];
if (process.env.NODE_ENV === 'production') {
if (!process.env.DATABASE_URL?.includes('sslmode=require')) {
errors.push('Production database must use SSL');
}
if (!process.env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET.length < 32) {
errors.push('Production must have secure auth secret (32+ characters)');
}
}
return errors;
}
2. Access Control Patterns
Implement consistent authorization:
// Authorization middleware pattern
export function requireRole(allowedRoles: string[]) {
return async (userId: string) => {
const user = await getUserById(userId);
if (!user) {
throw new Error('Authentication required');
}
if (!allowedRoles.includes(user.role)) {
throw new Error(`Access denied. Required roles: ${allowedRoles.join(', ')}`);
}
return user;
};
}
// Usage
const requireAdmin = requireRole(['admin']);
const requireUser = requireRole(['user', 'admin']);
export async function deleteUser(currentUserId: string, targetUserId: string) {
await requireAdmin(currentUserId);
// Admin-only operation
const database = await getRequiredDb();
return await database.delete(users).where(eq(users.id, targetUserId));
}
3. Input Sanitization
Always validate and sanitize:
import { z } from 'zod';
// Schema validation for security
const userUpdateSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
bio: z.string().max(500).optional(),
});
export async function updateUser(userId: string, updates: unknown) {
// Validate input before database operation
const validatedUpdates = userUpdateSchema.parse(updates);
const database = await getRequiredDb();
return await database
.update(users)
.set(validatedUpdates)
.where(eq(users.id, userId))
.returning();
}
4. Rate Limiting
Implement rate limiting for security:
// Rate limiting schema
export const rateLimits = pgTable('rate_limits', {
id: uuid('id').primaryKey().defaultRandom(),
identifier: text('identifier').notNull(), // IP or user ID
action: text('action').notNull(), // 'login', 'signup', etc.
count: integer('count').notNull().default(1),
windowStart: timestamp('window_start', { mode: 'date' }).notNull().defaultNow(),
createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(),
});
// Rate limiting implementation
export async function checkRateLimit(
identifier: string,
action: string,
maxAttempts: number = 5,
windowMinutes: number = 15
): Promise<{ allowed: boolean; remaining: number }> {
const database = await getRequiredDb();
const windowStart = new Date(Date.now() - windowMinutes * 60 * 1000);
// Count recent attempts
const [existing] = await database
.select({ count: count() })
.from(rateLimits)
.where(
and(
eq(rateLimits.identifier, identifier),
eq(rateLimits.action, action),
gte(rateLimits.windowStart, windowStart)
)
);
const currentCount = existing?.count || 0;
if (currentCount >= maxAttempts) {
return { allowed: false, remaining: 0 };
}
// Record this attempt
await database.insert(rateLimits).values({
identifier,
action,
count: 1,
});
return {
allowed: true,
remaining: maxAttempts - currentCount - 1
};
}
🔐 Encryption & Data Protection
Data at Rest
PostgreSQL encryption features:
- TDE (Transparent Data Encryption): Available in cloud providers
- Column-level encryption: For sensitive data
- Backup encryption: Encrypted database backups
Data in Transit
Connection encryption:
- SSL/TLS: All connections encrypted (
sslmode=require
) - Certificate validation: Verify server certificates
- Protocol security: Modern TLS versions only
Application-level Encryption
Encrypt sensitive data before storage:
import { createCipher, createDecipher } from 'crypto';
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!; // 32-byte key
export function encryptSensitive(data: string): string {
const cipher = createCipher('aes-256-cbc', ENCRYPTION_KEY);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return encrypted;
}
export function decryptSensitive(encryptedData: string): string {
const decipher = createDecipher('aes-256-cbc', ENCRYPTION_KEY);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// Usage for sensitive data
export async function storeSensitiveUserData(userId: string, sensitiveData: string) {
const encrypted = encryptSensitive(sensitiveData);
const database = await getRequiredDb();
await database
.update(users)
.set({ encryptedField: encrypted })
.where(eq(users.id, userId));
}
🎯 Next Steps
Strengthen your database security with these additional guides:
- Backup & Restore - Secure data protection
- Monitoring - Security monitoring
- Advanced - Advanced security patterns
- Performance - Secure performance optimization
Implementing comprehensive database security ensures your Solo Kit application protects user data and maintains trust at scale.