Building Robust Payment Systems: Server-Side Validation & IDOR Prevention
Introduction
In security-critical systems like smart lock management, payment flows represent one of the highest-risk attack surfaces. Recent security hardening in UnlockOS demonstrates essential patterns for building trustworthy payment systems: server-side fee validation, IDOR prevention, and atomic transaction handling.
Server-Side Fee Recomputation: Never Trust the Client
A fundamental security principle is never trusting client-provided financial data. UnlockOS implements server-side fee recomputation to prevent manipulation:
export async function reservationModify({
reservationId,
newStartTime,
newEndTime,
facilityId
}: ModifyRequest) {
// Client cannot manipulate fees - server calculates from source of truth
const recomputedFee = await calculateFeeFromPlan({
planId: reservation.planId,
startTime: newStartTime,
endTime: newEndTime,
facilityTimezone: facility.timezone
});
// Validate against business rules
if (isNaN(recomputedFee) || recomputedFee < 0 || !isFinite(recomputedFee)) {
throw new ValidationError('Invalid fee calculation result');
}
// Stripe minimum amount validation
if (recomputedFee > 0 && recomputedFee < STRIPE_MINIMUM_JPY) {
throw new ValidationError('Amount below payment processor minimum');
}
// Create payment intent with server-calculated amount
const paymentIntent = await createPaymentIntent({
amount: recomputedFee,
currency: facility.currency,
metadata: { reservationId, type: 'modification' }
});
return { paymentIntent, calculatedFee: recomputedFee };
}IDOR Prevention Through Multi-Layer Authorization
Insecure Direct Object Reference (IDOR) vulnerabilities allow attackers to access resources they shouldn't. UnlockOS implements defense-in-depth:
export async function authorizeReservationAccess(
userId: string,
reservationId: string,
action: 'read' | 'modify' | 'cancel'
): Promise<AuthorizationResult> {
// Layer 1: Verify reservation exists and user owns it
const reservation = await db
.from('reservations')
.select('*, facility:facilities(*)')
.eq('id', reservationId)
.eq('guest_user_id', userId)
.single();
if (!reservation) {
throw new UnauthorizedError('Reservation not found or access denied');
}
// Layer 2: Verify user has facility access
const hasAccess = await checkFacilityAccess(userId, reservation.facility.id);
if (!hasAccess) {
throw new UnauthorizedError('Facility access denied');
}
// Layer 3: Business rule validation
if (action === 'modify' && isPastReservation(reservation.start_time)) {
throw new BusinessRuleError('Cannot modify past reservations');
}
return { authorized: true, reservation, facility: reservation.facility };
}Atomic Transaction Patterns
Payment operations require atomicity to prevent inconsistent state. UnlockOS uses database transactions with rollback capabilities:
export async function processReservationExtension({
reservationId,
newEndTime,
userId
}: ExtensionRequest) {
return await db.transaction(async (trx) => {
try {
// 1. Lock the reservation row
const reservation = await trx
.from('reservations')
.select('*')
.eq('id', reservationId)
.eq('guest_user_id', userId)
.forUpdate()
.single();
// 2. Check for conflicts
const conflicts = await checkTimeSlotConflicts(
trx,
reservation.resource_id,
reservation.end_time,
newEndTime
);
if (conflicts.length > 0) {
throw new ConflictError('Time slot unavailable');
}
// 3. Calculate additional fee
const additionalFee = await calculateExtensionFee(reservation, newEndTime);
// 4. Process payment if required
let paymentIntent = null;
if (additionalFee > 0) {
paymentIntent = await createPaymentIntent({
amount: additionalFee,
currency: reservation.currency,
metadata: { reservationId, type: 'extension' }
});
}
// 5. Update reservation atomically
const updated = await trx
.from('reservations')
.update({
end_time: newEndTime,
updated_at: new Date().toISOString()
})
.eq('id', reservationId)
.select('*')
.single();
// 6. Log the operation for audit
await trx.from('audit_logs').insert({
entity_type: 'reservation',
entity_id: reservationId,
action: 'extend',
user_id: userId,
changes: { old_end_time: reservation.end_time, new_end_time: newEndTime },
timestamp: new Date().toISOString()
});
return { reservation: updated, paymentIntent };
} catch (error) {
// Transaction automatically rolls back
await logSecurityEvent({
event: 'reservation_extension_failed',
userId,
reservationId,
error: error.message
});
throw error;
}
});
}Input Validation and Sanitization
Robust input validation prevents injection attacks and data corruption:
import { z } from 'zod';
const ReservationModifySchema = z.object({
reservationId: z.string().uuid('Invalid reservation ID format'),
newStartTime: z.string().datetime('Invalid start time format'),
newEndTime: z.string().datetime('Invalid end time format'),
facilityId: z.string().uuid('Invalid facility ID format')
}).refine((data) => {
const start = new Date(data.newStartTime);
const end = new Date(data.newEndTime);
return end > start;
}, {
message: 'End time must be after start time'
});
export async function validateReservationModify(input: unknown) {
try {
return ReservationModifySchema.parse(input);
} catch (error) {
if (error instanceof z.ZodError) {
throw new ValidationError(
'Invalid input: ' + error.errors.map(e => e.message).join(', ')
);
}
throw error;
}
}Environment Variable Validation
Critical configuration errors should be caught at startup, not during runtime:
function validateEnvironment() {
const requiredVars = {
SUPABASE_URL: process.env.SUPABASE_URL,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY
};
const missing = Object.entries(requiredVars)
.filter(([_, value]) => !value)
.map(([key, _]) => key);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}. ` +
'Application cannot start safely without proper configuration.'
);
}
// Validate URL format
try {
new URL(requiredVars.SUPABASE_URL!);
} catch {
throw new Error('SUPABASE_URL must be a valid URL');
}
}
// Validate at module load time
validateEnvironment();Structured Error Responses
Consistent error handling improves security by preventing information leakage:
export class APIError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 500,
public details?: Record<string, unknown>
) {
super(message);
this.name = 'APIError';
}
toJSON() {
return {
error: {
code: this.code,
message: this.message,
...(process.env.NODE_ENV === 'development' && { details: this.details })
}
};
}
}
export function handleAPIError(error: unknown) {
if (error instanceof APIError) {
return Response.json(error.toJSON(), { status: error.statusCode });
}
// Log unexpected errors but don't expose details
console.error('Unexpected error:', error);
return Response.json(
{ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } },
{ status: 500 }
);
}Summary
Building trustworthy payment systems requires multiple layers of defense:
- Server-side validation prevents client-side manipulation
- Multi-layer authorization stops IDOR attacks
- Atomic transactions maintain data consistency
- Input validation prevents injection attacks
- Environment validation catches configuration errors early
- Structured error handling prevents information leakage
These patterns form the foundation of secure financial systems, ensuring that even under attack, the system maintains integrity and provides clear audit trails for investigation.