API Security
API authentication, input validation, SQL injection prevention, and secure API design.
API Security
Smart Shelf's API security framework ensures all API endpoints are protected against common attacks while maintaining performance and usability. Our multi-layered approach includes authentication, authorization, input validation, and comprehensive security monitoring.
API Authentication & Authorization
JWT Token Management
// lib/api/auth/jwt.ts
import jwt from 'jsonwebtoken'
import { User } from '@supabase/supabase-js'
interface JWTPayload {
sub: string // user ID
role: string
permissions: string[]
iat: number
exp: number
jti: string // token ID for revocation
}
export class APITokenManager {
static generateAPIToken(user: User, permissions: string[]): string {
const payload: JWTPayload = {
sub: user.id,
role: user.user_metadata?.role || 'viewer',
permissions,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60), // 24 hours
jti: crypto.randomUUID()
}
return jwt.sign(payload, process.env.JWT_SECRET!, {
algorithm: 'HS256',
issuer: 'smart-shelf-api',
audience: 'smart-shelf-client'
})
}
static async verifyAPIToken(token: string): Promise<JWTPayload> {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'],
issuer: 'smart-shelf-api',
audience: 'smart-shelf-client'
}) as JWTPayload
// Check if token is revoked
const isRevoked = await this.isTokenRevoked(payload.jti)
if (isRevoked) {
throw new Error('Token has been revoked')
}
return payload
} catch (error) {
throw new Error('Invalid or expired token')
}
}
static async revokeToken(tokenId: string): Promise<void> {
await supabase
.from('revoked_tokens')
.insert({
token_id: tokenId,
revoked_at: new Date().toISOString()
})
}
private static async isTokenRevoked(tokenId: string): Promise<boolean> {
const { data } = await supabase
.from('revoked_tokens')
.select('token_id')
.eq('token_id', tokenId)
.single()
return !!data
}
}
API Key Management
// lib/api/auth/api-keys.ts
export interface APIKey {
id: string
name: string
key_hash: string
permissions: string[]
rate_limit: number
expires_at?: string
last_used_at?: string
is_active: boolean
created_by: string
}
export class APIKeyManager {
static async generateAPIKey(
name: string,
permissions: string[],
userId: string,
options: { rateLimit?: number; expiresIn?: number } = {}
): Promise<{ id: string; key: string }> {
const id = crypto.randomUUID()
const key = `sk_${crypto.randomBytes(32).toString('hex')}`
const keyHash = await this.hashAPIKey(key)
const expiresAt = options.expiresIn
? new Date(Date.now() + options.expiresIn).toISOString()
: null
await supabase
.from('api_keys')
.insert({
id,
name,
key_hash: keyHash,
permissions,
rate_limit: options.rateLimit || 1000,
expires_at: expiresAt,
is_active: true,
created_by: userId
})
return { id, key }
}
static async validateAPIKey(key: string): Promise<APIKey | null> {
const keyHash = await this.hashAPIKey(key)
const { data: apiKey } = await supabase
.from('api_keys')
.select('*')
.eq('key_hash', keyHash)
.eq('is_active', true)
.single()
if (!apiKey) {
return null
}
// Check expiration
if (apiKey.expires_at && new Date(apiKey.expires_at) < new Date()) {
return null
}
// Update last used timestamp
await supabase
.from('api_keys')
.update({ last_used_at: new Date().toISOString() })
.eq('id', apiKey.id)
return apiKey
}
private static async hashAPIKey(key: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(key)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
}
Input Validation & Sanitization
Comprehensive Input Validation
// lib/api/validation/input-validator.ts
import { z } from 'zod'
import DOMPurify from 'isomorphic-dompurify'
export class InputValidator {
// Base validation schemas
static readonly baseSchemas = {
id: z.string().uuid('Invalid ID format'),
email: z.string().email('Invalid email format').max(255),
phone: z.string().regex(/^\+?[\d\s\-\(\)]+$/, 'Invalid phone format').max(20),
name: z.string().min(1).max(255).regex(/^[a-zA-Z0-9\s\-_.]+$/, 'Invalid characters in name'),
sku: z.string().min(1).max(100).regex(/^[A-Z0-9\-]+$/, 'SKU must contain only uppercase letters, numbers, and hyphens'),
currency: z.number().positive('Amount must be positive').max(999999.99),
url: z.string().url('Invalid URL format'),
slug: z.string().regex(/^[a-z0-9\-]+$/, 'Invalid slug format')
}
// Product validation schema
static readonly productSchema = z.object({
name: this.baseSchemas.name,
sku: this.baseSchemas.sku,
description: z.string().max(2000).optional(),
price: this.baseSchemas.currency,
cost_price: this.baseSchemas.currency.optional(),
category_id: this.baseSchemas.id,
supplier_id: this.baseSchemas.id.optional(),
barcode: z.string().regex(/^\d{8,14}$/, 'Invalid barcode format').optional(),
weight: z.number().positive().optional(),
dimensions: z.object({
length: z.number().positive(),
width: z.number().positive(),
height: z.number().positive()
}).optional(),
tags: z.array(z.string().max(50)).max(10).optional(),
images: z.array(this.baseSchemas.url).max(5).optional()
})
// User validation schema
static readonly userSchema = z.object({
email: this.baseSchemas.email,
full_name: z.string().min(2).max(100).regex(/^[a-zA-Z\s\-'\.]+$/, 'Invalid name characters'),
phone: this.baseSchemas.phone.optional(),
role: z.enum(['admin', 'manager', 'employee', 'viewer']),
department: z.string().min(1).max(100).optional(),
avatar_url: this.baseSchemas.url.optional()
})
// Inventory movement validation
static readonly inventoryMovementSchema = z.object({
product_id: this.baseSchemas.id,
warehouse_id: this.baseSchemas.id,
quantity: z.number().int().min(-999999).max(999999),
type: z.enum(['in', 'out', 'adjustment', 'transfer']),
reason: z.string().min(1).max(255),
reference_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional()
})
// Sanitize HTML content
static sanitizeHTML(input: string): string {
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: ['b', 'i', 'u', 'strong', 'em', 'p', 'br'],
ALLOWED_ATTR: []
})
}
// Sanitize user input
static sanitizeInput(input: any): any {
if (typeof input === 'string') {
return input
.trim()
.replace(/[\x00-\x1F\x7F]/g, '') // Remove control characters
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // Remove scripts
.slice(0, 10000) // Limit length
}
if (typeof input === 'object' && input !== null) {
const sanitized: any = Array.isArray(input) ? [] : {}
for (const key in input) {
if (input.hasOwnProperty(key)) {
sanitized[key] = this.sanitizeInput(input[key])
}
}
return sanitized
}
return input
}
// Check for potential SQL injection patterns
static containsSQLInjection(input: string): boolean {
const sqlPatterns = [
/(\s|^)(union|select|insert|update|delete|drop|create|alter|exec|execute)(\s|$)/i,
/(\s|^)(or|and)\s+\d+\s*=\s*\d+/i,
/'\s*(or|and)\s*'.*?'\s*=\s*'/i,
/(\s|^)(sleep|benchmark|waitfor)\s*\(/i,
/\/\*.*?\*\//,
/--[^\r\n]*/,
/;\s*(drop|delete|update|insert)/i
]
return sqlPatterns.some(pattern => pattern.test(input))
}
// Check for XSS patterns
static containsXSS(input: string): boolean {
const xssPatterns = [
/<script[^>]*>.*?<\/script>/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe[^>]*>.*?<\/iframe>/i,
/eval\s*\(/i,
/expression\s*\(/i,
/<object[^>]*>.*?<\/object>/i,
/<embed[^>]*>.*?<\/embed>/i
]
return xssPatterns.some(pattern => pattern.test(input))
}
// Validate and sanitize API input
static async validateAPIInput(
schema: z.ZodSchema,
input: unknown
): Promise<{ success: true; data: any } | { success: false; errors: string[] }> {
try {
// First sanitize the input
const sanitizedInput = this.sanitizeInput(input)
// Check for injection attacks
const stringified = JSON.stringify(sanitizedInput)
if (this.containsSQLInjection(stringified)) {
return { success: false, errors: ['Potential SQL injection detected'] }
}
if (this.containsXSS(stringified)) {
return { success: false, errors: ['Potential XSS attack detected'] }
}
// Validate with schema
const validatedData = schema.parse(sanitizedInput)
return { success: true, data: validatedData }
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.errors.map(e => `${e.path.join('.')}: ${e.message}`)
}
}
return { success: false, errors: ['Validation failed'] }
}
}
}
Request Validation Middleware
// lib/api/middleware/validation.ts
export function withValidation(
schema: z.ZodSchema,
options: {
validateQuery?: boolean
validateBody?: boolean
validateParams?: boolean
} = { validateBody: true }
) {
return function(handler: (req: NextRequest, context: any) => Promise<NextResponse>) {
return async function(request: NextRequest, context: any): Promise<NextResponse> {
try {
const validationErrors: string[] = []
// Validate request body
if (options.validateBody && (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH')) {
try {
const body = await request.json()
const bodyValidation = await InputValidator.validateAPIInput(schema, body)
if (!bodyValidation.success) {
validationErrors.push(...bodyValidation.errors)
} else {
// Add validated data to request context
context.validatedBody = bodyValidation.data
}
} catch (error) {
validationErrors.push('Invalid JSON in request body')
}
}
// Validate query parameters
if (options.validateQuery) {
const searchParams = Object.fromEntries(request.nextUrl.searchParams)
const queryValidation = await InputValidator.validateAPIInput(schema, searchParams)
if (!queryValidation.success) {
validationErrors.push(...queryValidation.errors)
} else {
context.validatedQuery = queryValidation.data
}
}
// Validate URL parameters
if (options.validateParams && context.params) {
const paramsValidation = await InputValidator.validateAPIInput(schema, context.params)
if (!paramsValidation.success) {
validationErrors.push(...paramsValidation.errors)
} else {
context.validatedParams = paramsValidation.data
}
}
if (validationErrors.length > 0) {
return NextResponse.json(
{
error: 'Validation failed',
details: validationErrors
},
{ status: 400 }
)
}
return await handler(request, context)
} catch (error) {
console.error('Validation middleware error:', error)
return NextResponse.json(
{ error: 'Internal validation error' },
{ status: 500 }
)
}
}
}
}
SQL Injection Prevention
Parameterized Queries with Supabase
// lib/api/database/safe-queries.ts
export class SafeQueryBuilder {
// Safe product queries with proper parameterization
static async getProducts(filters: {
category_id?: string
search?: string
min_price?: number
max_price?: number
tags?: string[]
limit?: number
offset?: number
}) {
const supabase = createClient()
let query = supabase
.from('products')
.select(`
id,
name,
sku,
description,
price,
category:categories(name),
supplier:suppliers(name),
inventory:inventory(quantity_on_hand)
`)
// Apply filters safely using Supabase's built-in parameterization
if (filters.category_id) {
query = query.eq('category_id', filters.category_id)
}
if (filters.search) {
// Use full-text search to prevent injection
query = query.textSearch('name', filters.search, {
type: 'websearch',
config: 'english'
})
}
if (filters.min_price !== undefined) {
query = query.gte('price', filters.min_price)
}
if (filters.max_price !== undefined) {
query = query.lte('price', filters.max_price)
}
if (filters.tags && filters.tags.length > 0) {
query = query.overlaps('tags', filters.tags)
}
// Apply pagination
const limit = Math.min(filters.limit || 50, 100) // Cap at 100
const offset = filters.offset || 0
const { data, error, count } = await query
.range(offset, offset + limit - 1)
.order('created_at', { ascending: false })
if (error) {
throw new Error(`Database query failed: ${error.message}`)
}
return { data, count }
}
// Safe inventory operations
static async createStockMovement(movement: {
product_id: string
warehouse_id: string
quantity: number
type: string
reason: string
reference_number?: string
notes?: string
created_by: string
}) {
const supabase = createClient()
// Use database transaction for consistency
const { data, error } = await supabase.rpc('create_stock_movement', {
p_product_id: movement.product_id,
p_warehouse_id: movement.warehouse_id,
p_quantity: movement.quantity,
p_movement_type: movement.type,
p_reason: movement.reason,
p_reference_number: movement.reference_number,
p_notes: movement.notes,
p_created_by: movement.created_by
})
if (error) {
throw new Error(`Stock movement failed: ${error.message}`)
}
return data
}
// Safe user queries with proper access control
static async getUsersByRole(role: string, requestingUserId: string) {
const supabase = createClient()
// First check if requesting user has permission
const { data: requestingUser } = await supabase
.from('users')
.select('role')
.eq('id', requestingUserId)
.single()
if (!requestingUser || !['admin', 'manager'].includes(requestingUser.role)) {
throw new Error('Insufficient permissions')
}
// Use parameterized query
const { data, error } = await supabase
.from('users')
.select('id, email, full_name, role, created_at, last_login')
.eq('role', role)
.eq('is_active', true)
.order('created_at', { ascending: false })
if (error) {
throw new Error(`User query failed: ${error.message}`)
}
return data
}
}
Database Function Security
-- Secure stored procedures with proper parameter validation
CREATE OR REPLACE FUNCTION create_stock_movement(
p_product_id UUID,
p_warehouse_id UUID,
p_quantity INTEGER,
p_movement_type TEXT,
p_reason TEXT,
p_reference_number TEXT DEFAULT NULL,
p_notes TEXT DEFAULT NULL,
p_created_by UUID
)
RETURNS JSON AS $$
DECLARE
v_current_stock INTEGER;
v_new_stock INTEGER;
v_movement_id UUID;
v_result JSON;
BEGIN
-- Input validation
IF p_product_id IS NULL OR p_warehouse_id IS NULL OR p_created_by IS NULL THEN
RAISE EXCEPTION 'Required parameters cannot be null';
END IF;
IF p_quantity = 0 THEN
RAISE EXCEPTION 'Quantity cannot be zero';
END IF;
IF p_movement_type NOT IN ('in', 'out', 'adjustment', 'transfer') THEN
RAISE EXCEPTION 'Invalid movement type';
END IF;
IF length(p_reason) < 3 OR length(p_reason) > 255 THEN
RAISE EXCEPTION 'Reason must be between 3 and 255 characters';
END IF;
-- Check if user has permission
IF NOT EXISTS (
SELECT 1 FROM users
WHERE id = p_created_by
AND role IN ('admin', 'manager', 'employee')
AND is_active = true
) THEN
RAISE EXCEPTION 'User does not have permission to create stock movements';
END IF;
-- Check if product and warehouse exist
IF NOT EXISTS (SELECT 1 FROM products WHERE id = p_product_id) THEN
RAISE EXCEPTION 'Product not found';
END IF;
IF NOT EXISTS (SELECT 1 FROM warehouses WHERE id = p_warehouse_id) THEN
RAISE EXCEPTION 'Warehouse not found';
END IF;
-- Begin transaction
BEGIN
-- Get current stock
SELECT COALESCE(quantity_on_hand, 0) INTO v_current_stock
FROM inventory
WHERE product_id = p_product_id AND warehouse_id = p_warehouse_id;
-- Calculate new stock
v_new_stock := v_current_stock + p_quantity;
-- Prevent negative stock (except for adjustments)
IF v_new_stock < 0 AND p_movement_type != 'adjustment' THEN
RAISE EXCEPTION 'Insufficient stock. Current: %, Requested: %', v_current_stock, ABS(p_quantity);
END IF;
-- Generate movement ID
v_movement_id := gen_random_uuid();
-- Create stock movement record
INSERT INTO stock_movements (
id, product_id, warehouse_id, quantity, type, reason,
reference_number, notes, created_by, created_at
) VALUES (
v_movement_id, p_product_id, p_warehouse_id, p_quantity, p_movement_type,
p_reason, p_reference_number, p_notes, p_created_by, NOW()
);
-- Update inventory
INSERT INTO inventory (product_id, warehouse_id, quantity_on_hand, updated_at)
VALUES (p_product_id, p_warehouse_id, v_new_stock, NOW())
ON CONFLICT (product_id, warehouse_id)
DO UPDATE SET
quantity_on_hand = v_new_stock,
updated_at = NOW();
-- Create audit log
INSERT INTO audit_logs (
table_name, record_id, action, new_values, user_id, created_at
) VALUES (
'stock_movements', v_movement_id, 'INSERT',
jsonb_build_object(
'product_id', p_product_id,
'warehouse_id', p_warehouse_id,
'quantity', p_quantity,
'type', p_movement_type,
'previous_stock', v_current_stock,
'new_stock', v_new_stock
),
p_created_by, NOW()
);
-- Build result
v_result := jsonb_build_object(
'movement_id', v_movement_id,
'previous_stock', v_current_stock,
'new_stock', v_new_stock,
'success', true
);
RETURN v_result;
EXCEPTION
WHEN OTHERS THEN
-- Log the error
INSERT INTO error_logs (
error_message, error_context, created_at
) VALUES (
SQLERRM,
jsonb_build_object(
'function', 'create_stock_movement',
'parameters', jsonb_build_object(
'product_id', p_product_id,
'warehouse_id', p_warehouse_id,
'quantity', p_quantity,
'type', p_movement_type
)
),
NOW()
);
RAISE;
END;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
API Security Best Practices
Secure API Design Patterns
// lib/api/patterns/secure-api.ts
export class SecureAPIPatterns {
// Pagination with security controls
static validatePagination(query: URLSearchParams): { limit: number; offset: number } {
const limit = Math.min(parseInt(query.get('limit') || '50'), 100) // Max 100 items
const offset = Math.max(parseInt(query.get('offset') || '0'), 0) // Non-negative
return { limit, offset }
}
// Filtering with whitelist approach
static validateFilters(filters: any, allowedFields: string[]): Record<string, any> {
const validFilters: Record<string, any> = {}
for (const [key, value] of Object.entries(filters)) {
if (allowedFields.includes(key) && value !== undefined && value !== '') {
validFilters[key] = value
}
}
return validFilters
}
// Sorting with security controls
static validateSorting(sort: string, allowedFields: string[]): { field: string; direction: 'asc' | 'desc' } {
const [field, direction = 'asc'] = sort.split(':')
if (!allowedFields.includes(field)) {
throw new Error(`Invalid sort field: ${field}`)
}
if (!['asc', 'desc'].includes(direction)) {
throw new Error(`Invalid sort direction: ${direction}`)
}
return { field, direction: direction as 'asc' | 'desc' }
}
// Response data masking based on user permissions
static maskResponseData(data: any, userRole: string, sensitiveFields: string[]): any {
if (userRole === 'admin') {
return data // Admins see everything
}
if (Array.isArray(data)) {
return data.map(item => this.maskObject(item, userRole, sensitiveFields))
}
return this.maskObject(data, userRole, sensitiveFields)
}
private static maskObject(obj: any, userRole: string, sensitiveFields: string[]): any {
if (!obj || typeof obj !== 'object') {
return obj
}
const masked = { ...obj }
sensitiveFields.forEach(field => {
if (field in masked) {
// Apply role-based masking
if (userRole === 'viewer') {
masked[field] = '***'
} else if (userRole === 'employee' && field.includes('cost')) {
masked[field] = '***'
}
}
})
return masked
}
// Error response standardization
static createErrorResponse(
error: string,
details?: string[],
statusCode: number = 400
): NextResponse {
const response = {
error,
details: details || [],
timestamp: new Date().toISOString(),
reference: crypto.randomUUID()
}
// Don't expose sensitive error details in production
if (process.env.NODE_ENV === 'production' && statusCode >= 500) {
response.details = []
}
return NextResponse.json(response, { status: statusCode })
}
// Success response standardization
static createSuccessResponse(
data: any,
meta?: { total?: number; page?: number; limit?: number }
): NextResponse {
const response: any = {
data,
success: true,
timestamp: new Date().toISOString()
}
if (meta) {
response.meta = meta
}
return NextResponse.json(response)
}
}
API Security Middleware Chain
// lib/api/middleware/security-chain.ts
export function createSecureAPIHandler(
handler: (req: NextRequest, context: any) => Promise<NextResponse>,
options: {
requireAuth?: boolean
requiredPermissions?: string[]
rateLimiter?: Ratelimit
validation?: {
body?: z.ZodSchema
query?: z.ZodSchema
params?: z.ZodSchema
}
sensitiveFields?: string[]
} = {}
) {
return async function(request: NextRequest, context: any): Promise<NextResponse> {
try {
// 1. WAF Protection
const wafCheck = await WebApplicationFirewall.analyzeRequest(request)
if (wafCheck.blocked) {
return SecureAPIPatterns.createErrorResponse(
'Request blocked by security policy',
wafCheck.reasons,
403
)
}
// 2. Rate Limiting
if (options.rateLimiter) {
const ip = request.headers.get('x-forwarded-for') || 'unknown'
const rateLimitResult = await RateLimitManager.applyRateLimit(
ip,
options.rateLimiter,
request
)
if (rateLimitResult) {
return rateLimitResult
}
}
// 3. Authentication
let user = null
if (options.requireAuth) {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return SecureAPIPatterns.createErrorResponse(
'Authentication required',
['Missing or invalid authorization header'],
401
)
}
const token = authHeader.split(' ')[1]
try {
const payload = await APITokenManager.verifyAPIToken(token)
user = { id: payload.sub, role: payload.role, permissions: payload.permissions }
context.user = user
} catch (error) {
return SecureAPIPatterns.createErrorResponse(
'Invalid token',
[error.message],
401
)
}
}
// 4. Authorization
if (options.requiredPermissions && options.requiredPermissions.length > 0) {
if (!user) {
return SecureAPIPatterns.createErrorResponse(
'Authentication required for this operation',
[],
401
)
}
const hasPermission = options.requiredPermissions.every(
permission => user.permissions.includes(permission)
)
if (!hasPermission) {
return SecureAPIPatterns.createErrorResponse(
'Insufficient permissions',
[`Required: ${options.requiredPermissions.join(', ')}`],
403
)
}
}
// 5. Input Validation
if (options.validation) {
if (options.validation.body) {
const bodyValidation = withValidation(options.validation.body, { validateBody: true })
const validationResult = await bodyValidation(handler)(request, context)
if (validationResult.status === 400) {
return validationResult
}
}
if (options.validation.query) {
const queryParams = Object.fromEntries(request.nextUrl.searchParams)
const queryValidation = await InputValidator.validateAPIInput(
options.validation.query,
queryParams
)
if (!queryValidation.success) {
return SecureAPIPatterns.createErrorResponse(
'Invalid query parameters',
queryValidation.errors,
400
)
}
context.validatedQuery = queryValidation.data
}
}
// 6. Execute handler
const response = await handler(request, context)
// 7. Response processing
if (response.status < 400 && options.sensitiveFields && user) {
const responseData = await response.json()
if (responseData.data) {
responseData.data = SecureAPIPatterns.maskResponseData(
responseData.data,
user.role,
options.sensitiveFields
)
}
return NextResponse.json(responseData, { status: response.status })
}
return response
} catch (error) {
console.error('API Security Chain Error:', error)
return SecureAPIPatterns.createErrorResponse(
'Internal server error',
[],
500
)
}
}
}
This comprehensive API security framework ensures that all Smart Shelf APIs are protected against common attack vectors while maintaining performance and developer experience.