DEV Community

Renato Silva
Renato Silva

Posted on

I Stopped Trusting AI Agents With My API

🤖 The Problem

A few weeks ago I wired an LLM agent up to minimalist-feedback-api, my little side project for collecting product feedback. The pitch to myself was simple: let a support-bot agent read feedback threads and occasionally write a triage note or close a stale ticket, without me manually reviewing every call.

It took about two days for the agent to do something I didn't ask for.

Nothing catastrophic — it bulk-updated the status of a dozen feedback items because it decided, on its own, that they were "resolved" based on a fuzzy read of the conversation. Technically it used an endpoint I'd exposed to it. Technically the request was authenticated. But nobody had actually agreed that an agent should be allowed to do bulk writes, and there was no record of why it thought that was a good idea.

That's the part that got me. With a human client, a bad API call is a bug. With an agent, a bad API call is a decision, made by a system that can also decide to make it again, faster, in a loop, at 3am.

So I stopped trusting agents with the same trust model I give human-driven clients, and built a gatekeeper middleware specifically for tool-calling traffic.

🔐 What "Trust" Even Means for an Agent

Before writing code, I had to get concrete about what I was actually worried about. It came down to three things:

  1. Scope — an API key belonging to "the support agent" should not be able to call every write endpoint just because it's authenticated.
  2. Rate — agents don't get bored or embarrassed. A misbehaving loop can hit your API way harder than a person ever would.
  3. Audit — when something weird happens, I need to reconstruct not just what was called, but which agent, with what identity, doing what it claimed to be doing.

Regular auth middleware answers "who are you." This needed to answer "are you allowed to do this specific thing, right now, at this rate, and is someone going to know about it."

🏗️ The Shape of the Middleware

minimalist-feedback-api has a handful of write endpoints: create feedback, update status, delete feedback, bulk operations. I treated agent access as a distinct concern from normal API auth — it sits after authentication and before the route handler.

js
// middleware/agentGatekeeper.js
const agentScopes = {
'agent:support-triage': ['feedback:update-status', 'feedback:read'],
'agent:analytics-readonly': ['feedback:read'],
};

function requireAgentScope(action) {
return (req, res, next) => {
const agentId = req.headers['x-agent-id'];
const agentToken = req.headers['x-agent-token'];

if (!agentId) {
  // Not an agent request, let normal auth handle it
  return next();
}

if (!verifyAgentToken(agentId, agentToken)) {
  return res.status(401).json({ error: 'invalid agent credentials' });
}

const allowed = agentScopes[agentId] || [];
if (!allowed.includes(action)) {
  return res.status(403).json({
    error: `agent '${agentId}' is not scoped for action '${action}'`,
  });
}

req.agent = { id: agentId, action };
next();
Enter fullscreen mode Exit fullscreen mode

};
}

module.exports = { requireAgentScope };

The key decision here: scopes are actions, not endpoints. feedback:update-status and feedback:delete are separate permissions even though they might hit similar routes, because "update a status field" and "permanently delete a record" are very different risk levels. My support-triage agent gets the former, never the latter. No agent in this system currently has delete access, on purpose — if it needs to happen, a human does it.

🚦 Rate-Limiting Per Agent, Not Per IP

Standard rate limiters key off IP address, which is close to useless for agents — they usually run from the same handful of server IPs as your other backend traffic. I keyed limiting off the agent identity instead, with tighter windows than I'd ever apply to a human-facing key:

js
const rateLimit = require('express-rate-limit');

const agentWriteLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5, // an agent doing >5 writes/min is suspicious, full stop
keyGenerator: (req) => req.agent?.id || req.ip,
handler: (req, res) => {
logAgentEvent({
agentId: req.agent?.id,
action: req.agent?.action,
outcome: 'rate_limited',
});
res.status(429).json({ error: 'agent rate limit exceeded' });
},
});

Five writes a minute felt aggressive when I set it, but it's forced something useful: if the agent legitimately needs to do more than that, it should be batching its reasoning into fewer, larger, more deliberate calls — not firing off a write per sentence of its own chain of thought.

📝 Auditing: The Part I Actually Use Every Day

Scopes and rate limits prevent damage. The audit log is what lets me trust the system incrementally instead of all-or-nothing. Every agent-originated write gets logged with enough context to answer "why did this happen" without me guessing:

js
function auditAgentWrite(req, res, next) {
const original = res.json.bind(res);
res.json = (body) => {
if (req.agent) {
logAgentEvent({
agentId: req.agent.id,
action: req.agent.action,
method: req.method,
path: req.originalUrl,
requestBody: req.body,
statusCode: res.statusCode,
timestamp: new Date().toISOString(),
});
}
return original(body);
};
next();
}

Wiring it all together on a real route looks like this:

js
router.patch(
'/feedback/:id/status',
requireAgentScope('feedback:update-status'),
agentWriteLimiter,
auditAgentWrite,
updateFeedbackStatus,
);

The log entries go to a plain table (agent_audit_log) rather than a generic app log stream, because I wanted to query it directly: "show me every write agent:support-triage made in the last 24 hours" is a query I actually run now, especially after a prompt or model change.

⚖️ Trade-offs I'm Consciously Accepting

This isn't zero-cost. Static scope tables mean I have to redeploy to change what an agent can do — I'm fine with that friction on purpose, because "redeploy to expand agent permissions" is a feature, not a bug, at this stage. A more dynamic, database-backed scope system would remove the friction and also remove the forcing function that makes me think twice.

I also don't do anything fancy with the audit data yet — no anomaly detection, no auto-revocation. It's a log I read. That's deliberately unglamorous; I'd rather have a boring, reliable trail than a clever system I don't fully understand when it fires.

🙋 Over to You

If you're letting an agent call real write endpoints today, what's actually stopping it from doing something scoped, rate-limited access wouldn't have caught anyway? I'm curious whether people are seeing failure modes that permission systems can't touch — like an agent staying within scope but still making bad judgment calls.

Happy to share the full minimalist-feedback-api gatekeeper module if there's interest — it's small enough to drop into most Express projects in an afternoon.

Top comments (0)