Skip to main content

Command Palette

Search for a command to run...

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

Updated
6 min read
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 };
  }
}

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.

Why this matters for automation platforms

Rate caps exist for three reasons:

1. Platform safety. X rate-limits accounts that send too many actions. 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.

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
└───────────────────────────────┘

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);

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

  return row.count;
}

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

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 };
}

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.

Level 3: Immutable audit trail

Every action — successful or rejected — is logged. The audit log serves dual purpose: it's both the record of what happened and the data source for cap calculation. You can't have a successful action without a log entry.

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);
  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;
}

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, but never raise it above what their plan allows.

The plan-level cap lives in the global database — separate from the slot database. Layered trust boundaries prevent privilege escalation.

Race condition prevention

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

  return db.transaction(() => {
    const cap = getSlotCap(slotId);
    const used = getDailyActionCount(slotId);

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

    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 };
  })();
}

SQLite's write lock serializes cap checks. Two concurrent checks cannot both pass if only one slot remains.

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);
  const avgPerHour = dailyCap / windowHours;
  return Math.ceil(avgPerHour * 1.5);
}

For 100 actions in a 12-hour window: average is 8.3/hour, burst limit is 13/hour. This prevents front-loading.

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);

  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;
}

Using epoch-based calculation rather than formatted date strings makes the cap resistant to locale or timezone configuration changes.

What we learned

1. The database IS the cap. Don't maintain a separate counter. Count the rows.

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.

3. Atomic check-and-reserve prevents overrun. Wrap the cap check and action reservation in a transaction.

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

5. Cap exhaustion is an expected state, not an error. Design the UX around it.

6. Client trust is always a security bug in multi-tenant systems. 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.