DEV Community

Cover image for Server-Side Rate Caps You Can't Bypass: Why Client Trust Is a Security Bug
HelperX
HelperX

Posted on • Originally published at helperx.app

Server-Side Rate Caps You Can't Bypass: Why Client Trust Is a Security Bug

Every automation platform has limits. Daily action caps, hourly quotas, request budgets. The question isn't whether you enforce them — it's where.

If the answer is "the client decides when to stop," you don't have a rate cap. You have a suggestion.

Here's how we built server-side rate enforcement in HelperX that operators cannot bypass — even if they modify the client, reverse-engineer the API, or tamper with local state.

The client trust problem

Consider a typical architecture where the client tracks its own usage:

// CLIENT-SIDE CAP (vulnerable)
class ActionScheduler {
  constructor(dailyCap) {
    this.dailyCap = dailyCap;
    this.actionsToday = 0;
  }

  async execute(action) {
    if (this.actionsToday >= this.dailyCap) {
      return { skipped: true, reason: 'cap_reached' };
    }

    await action();
    this.actionsToday++;
    return { success: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

This looks correct. It even works — until someone:

  1. Modifies this.dailyCap in memory
  2. Resets this.actionsToday to zero mid-day
  3. Calls action() directly, bypassing the scheduler
  4. Restarts the process (counter resets to 0)
  5. Runs multiple instances of the process

The cap exists only in RAM. It evaporates on restart. It's trivially bypassable by anyone with access to the runtime — which, in a desktop app or self-hosted tool, is every user.

This isn't a theoretical vulnerability. We've seen automation tools where the "Pro plan limit" is a JavaScript variable that users patch with a debugger.

Why this matters for automation platforms

Rate caps exist for three reasons:

1. Platform safety. X rate-limits accounts that send too many actions. Exceeding caps gets accounts flagged, rate-limited, or suspended. The cap protects the user from themselves.

2. Fair resource allocation. Each account consumes proxy bandwidth, AI tokens, and API calls. Caps ensure one user doesn't exhaust shared resources.

3. Behavioral realism. No human sends 500 replies in a day. Caps enforce realistic activity patterns that avoid detection.

If an operator bypasses the cap, they don't just risk their own account — they degrade the proxy pool for everyone, burn shared AI token budgets, and trigger platform-wide rate limits that affect other users.

Client-side enforcement treats the cap as a UX feature. Server-side enforcement treats it as a security boundary.

The server-side architecture

In HelperX, the cap is enforced at three levels:

┌───────────────────────────────┐
│   Level 1: Database Counter   │  ← Source of truth
├───────────────────────────────┤
│   Level 2: Pre-execution Gate │  ← Check before every action
├───────────────────────────────┤
│   Level 3: Post-execution Log │  ← Immutable audit trail
└───────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Level 1: Database as source of truth

The daily count lives in SQLite, not in memory:

function getDailyActionCount(slotId) {
  const db = getDb(slotId);
  const today = new Date().toISOString().slice(0, 10); // UTC date

  const row = db.prepare(`
    SELECT COUNT(*) as count FROM audit_log
    WHERE status = 'success'
    AND date(timestamp) = ?
  `).get(today);

  return row.count;
}
Enter fullscreen mode Exit fullscreen mode

The count is derived from actual logged actions — not from an incrementing variable. You can't fake it without writing to the database, and the database is the server's authority.

Restarting the process? The count persists. Running multiple instances? They all read the same database. Modifying memory? The next action re-queries the database.

Level 2: Pre-execution gate

Every action passes through a gate before execution:

async function executeWithCapEnforcement(slotId, module, actionFn) {
  const cap = getSlotCap(slotId);
  const used = getDailyActionCount(slotId);

  if (used >= cap) {
    logAudit(slotId, module, 'cap_reached', {
      used,
      cap,
      nextReset: getNextCapReset()
    });
    return { executed: false, reason: 'daily_cap_reached' };
  }

  const moduleCap = getModuleCap(slotId, module);
  const moduleUsed = getModuleActionCount(slotId, module);

  if (moduleUsed >= moduleCap) {
    logAudit(slotId, module, 'module_cap_reached', {
      used: moduleUsed,
      cap: moduleCap
    });
    return { executed: false, reason: 'module_cap_reached' };
  }

  const result = await actionFn();

  logAudit(slotId, module, 'success', result);
  return { executed: true, result };
}
Enter fullscreen mode Exit fullscreen mode

The gate checks two caps:

  1. Global daily cap — total actions across all modules for this slot
  2. Module-level cap — per-module limits (e.g., max 50 replies, max 10 DMs)

Both are checked immediately before execution. No cached values. No stale counters. The database is queried every time.

Level 3: Immutable audit trail

Every action — successful or rejected — is logged:

function logAudit(slotId, module, status, detail) {
  const db = getDb(slotId);

  db.prepare(`
    INSERT INTO audit_log (id, module, action, status, detail, timestamp)
    VALUES (?, ?, ?, ?, ?, datetime('now'))
  `).run(
    crypto.randomUUID(),
    module,
    'execute',
    status,
    JSON.stringify(detail)
  );
}
Enter fullscreen mode Exit fullscreen mode

The audit log serves dual purpose: it's both the record of what happened and the data source for cap calculation. These are the same thing. You can't have a successful action without a log entry, and you can't inflate the log without the corresponding action having occurred.

Cap configuration: server-controlled

Where does the cap value itself come from? Not from a config file the operator edits.

function getSlotCap(slotId) {
  const db = getDb(slotId);

  const row = db.prepare(`
    SELECT value FROM config WHERE key = 'daily_cap'
  `).get();

  if (!row) return DEFAULT_CAP;

  const cap = parseInt(row.value, 10);
  const maxAllowed = getMaxCapForPlan(slotId);

  // Cap can never exceed plan limit
  return Math.min(cap, maxAllowed);
}

function getMaxCapForPlan(slotId) {
  const globalDb = getGlobalDb();
  const slot = globalDb.prepare(`
    SELECT u.plan FROM slots s
    JOIN users u ON s.user_id = u.id
    WHERE s.id = ?
  `).get(slotId);

  const planLimits = {
    free: 30,
    starter: 100,
    pro: 300,
    enterprise: 1000
  };

  return planLimits[slot?.plan] || planLimits.free;
}
Enter fullscreen mode Exit fullscreen mode

Even if an operator sets their daily cap to 9999 in the slot config, getSlotCap enforces the plan ceiling. The operator can lower their cap (for safety), but never raise it above what their plan allows.

The plan-level cap lives in the global database — separate from the slot database. An operator with access to their slot's SQLite file still can't modify their plan limits.

Race condition prevention

What if two actions execute simultaneously and both pass the cap check?

function executeWithAtomicCapCheck(slotId, module, actionFn) {
  const db = getDb(slotId);

  // SQLite's write lock ensures atomicity
  return db.transaction(() => {
    const cap = getSlotCap(slotId);
    const used = getDailyActionCount(slotId);

    if (used >= cap) {
      return { executed: false, reason: 'daily_cap_reached' };
    }

    // Reserve the slot by logging intent
    const actionId = crypto.randomUUID();
    db.prepare(`
      INSERT INTO audit_log (id, module, action, status, detail, timestamp)
      VALUES (?, ?, 'execute', 'pending', '', datetime('now'))
    `).run(actionId, module);

    return { proceed: true, actionId };
  })();
}
Enter fullscreen mode Exit fullscreen mode

SQLite's write lock serializes cap checks. Two concurrent checks cannot both pass if only one slot remains — the transaction ensures check-and-reserve is atomic.

For our architecture (one scheduler loop per slot, sequential execution), this race condition doesn't occur in practice. But the atomic check exists as a safety net — defense in depth.

Hourly sub-caps

A daily cap of 100 doesn't mean "send 100 actions in the first hour." We enforce hourly distribution:

function getHourlyBudget(slotId) {
  const config = getSlotConfig(slotId);
  const windowHours = config.workTime.endHour - config.workTime.startHour;
  const dailyCap = getSlotCap(slotId);

  // Allow 1.5x the average hourly rate (burst tolerance)
  const avgPerHour = dailyCap / windowHours;
  return Math.ceil(avgPerHour * 1.5);
}

function isHourlyBudgetExhausted(slotId) {
  const db = getDb(slotId);
  const hourAgo = new Date(Date.now() - 3600_000).toISOString();

  const row = db.prepare(`
    SELECT COUNT(*) as count FROM audit_log
    WHERE status = 'success'
    AND timestamp >= ?
  `).get(hourAgo);

  return row.count >= getHourlyBudget(slotId);
}
Enter fullscreen mode Exit fullscreen mode

For 100 actions in a 12-hour window: average is 8.3/hour, burst limit is 13/hour. This prevents front-loading and ensures activity spreads across the work window.

What happens when the cap is hit

The system doesn't error. It doesn't retry. It stops cleanly:

async function onCapReached(slotId, module, capType) {
  const nextReset = getNextCapReset();
  const sleepMs = nextReset - Date.now();

  logAudit(slotId, module, 'cap_reached', {
    type: capType,
    nextReset: new Date(nextReset).toISOString(),
    sleepMs
  });

  // Notify dashboard
  emitSlotEvent(slotId, 'cap_reached', {
    module,
    type: capType,
    resumesAt: new Date(nextReset).toISOString()
  });

  // Sleep until next reset
  await sleep(sleepMs);
}
Enter fullscreen mode Exit fullscreen mode

The operator sees exactly when the cap was hit and when it resets. No ambiguity, no manual intervention needed.

Defending against clock manipulation

If the server clock is manipulated, the UTC date changes, and caps reset early. Defense:

function isDailyCapReached(slotId) {
  const db = getDb(slotId);
  const now = Date.now();
  const dayStart = now - (now % 86_400_000); // UTC day boundary from epoch

  const row = db.prepare(`
    SELECT COUNT(*) as count FROM audit_log
    WHERE status = 'success'
    AND timestamp >= datetime(? / 1000, 'unixepoch')
  `).get(dayStart);

  const cap = getSlotCap(slotId);
  return row.count >= cap;
}
Enter fullscreen mode Exit fullscreen mode

Using epoch-based calculation rather than formatted date strings makes the cap resistant to locale or timezone configuration changes. The 86,400,000ms boundary is absolute.

Monitoring cap utilization

The dashboard shows real-time cap status:

function getCapStatus(slotId) {
  const cap = getSlotCap(slotId);
  const used = getDailyActionCount(slotId);
  const hourlyBudget = getHourlyBudget(slotId);
  const hourlyUsed = getHourlyActionCount(slotId);

  return {
    daily: { used, cap, remaining: cap - used, pct: Math.round(used / cap * 100) },
    hourly: { used: hourlyUsed, budget: hourlyBudget, pct: Math.round(hourlyUsed / hourlyBudget * 100) },
    nextReset: getNextCapReset(),
    pace: used > 0 ? calculatePace(slotId) : null
  };
}

function calculatePace(slotId) {
  const config = getSlotConfig(slotId);
  const now = new Date();
  const elapsedHours = getElapsedWorkHours(config.workTime, now);
  const remainingHours = getRemainingWorkHours(config.workTime, now);
  const used = getDailyActionCount(slotId);
  const cap = getSlotCap(slotId);
  const remaining = cap - used;

  return {
    currentRate: Math.round(used / Math.max(elapsedHours, 0.1)),
    requiredRate: remainingHours > 0 ? Math.round(remaining / remainingHours) : 0,
    onTrack: (remaining / Math.max(remainingHours, 0.1)) <= (cap / getWindowHours(config.workTime) * 1.5)
  };
}
Enter fullscreen mode Exit fullscreen mode

Operators see: how much budget remains, current pace vs. required pace, and whether they'll exhaust the cap before the window closes.

What we learned

1. The database IS the cap. Don't maintain a separate counter. Count the rows. The audit log is both the record and the enforcement mechanism. One source of truth eliminates drift.

2. Plan limits must live in a separate trust boundary. The operator controls their slot database. Plan-level maximums live in the global database they can't modify. Layered trust boundaries prevent privilege escalation.

3. Atomic check-and-reserve prevents overrun. Even in a single-writer architecture, wrap the cap check and action reservation in a transaction. The cost is negligible; the safety guarantee is absolute.

4. Hourly sub-caps prevent burst patterns. A daily cap alone allows 100 actions in 30 minutes. Hourly budgets enforce distribution without requiring complex scheduling logic.

5. Cap exhaustion is an expected state, not an error. Design the UX around it: show when it will reset, show pace data, make it informational rather than alarming.

6. Client trust is always a security bug in multi-tenant systems. If one user can exceed their limits, they degrade the system for everyone. Server enforcement isn't optional — it's the entire point.


HelperX enforces server-side daily caps with per-slot SQLite audit logs — no client-side trust, no bypasses. Free 30-day trial.

Top comments (0)