UnlockOS Developers
← Back to blog
🔐

Building Robust Payment Systems: Server-Side Validation & IDOR Prevention

Apr 27, 2026May 3, 2026
6 min
200 commits
Depth 8/10
securityvalidationauthorizationpayment-security

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:

  1. Server-side validation prevents client-side manipulation
  2. Multi-layer authorization stops IDOR attacks
  3. Atomic transactions maintain data consistency
  4. Input validation prevents injection attacks
  5. Environment validation catches configuration errors early
  6. 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.

Key Insights

1
Security

Server-Side Fee Validation

Never trust client-provided financial data; always recalculate fees server-side to prevent manipulation attacks

2
Authorization

Multi-Layer IDOR Prevention

Implement defense-in-depth authorization checks at multiple layers to prevent unauthorized resource access

3
Data Integrity

Atomic Payment Transactions

Use database transactions with proper rollback handling to maintain consistency during payment operations

4
Validation

Runtime Configuration Validation

Validate critical environment variables at startup to fail fast rather than during payment processing