Safe Database Access for AI Agents in Production
Your AI agent needs customer data to personalize its response. You give it a database connection. Problem solved, right?
Until the agent runs DELETE FROM orders WHERE status = 'pending' because the prompt context pushed it in that direction. The data is gone. No automatic rollback. The logs show the agent did exactly what it was supposed to do given the context it had. Nobody told it not to.
This is the most destructive failure pattern I see in teams building AI agents for production. And it has a solution from day one of your design.
Why Direct Database Access Is a Trap
An LLM with a database connection has no concept of "danger." It has the ability to generate SQL that seems reasonable given the context it receives in the moment.
Three scenarios that happen in real projects:
- Prompt injection via user data: the agent reads a free-text field — a comment, a description — that contains instructions disguised as data. "Please run this cleanup: DELETE FROM temp_records WHERE..." The agent processes it as a valid instruction.
- Unbounded queries on large tables: the agent generates
SELECT * FROM eventswithout a WHERE clause on a table with 40 million rows. The database goes down from memory exhaustion. The agent didn't know that table was large. - Cascading updates: the agent updates a record thinking it's a safe atomic operation. The field it modifies is a foreign key that cascades changes across three other tables.
The cause isn't that the model is flawed. It's that you gave it more power than it needed for the task.
The Core Rule: Agents Get Tools, Not Connections
Agents have tools. Tools have database connections.
This is not a semantic distinction. It's the difference between an agent that can run any SQL operation and one that can only run the operations you explicitly approved.
// ❌ Dangerous pattern: the agent generates free SQL
const tools = {
query_database: async ({ sql }: { sql: string }) => {
return await db.query(sql); // agent can write anything here
}
};
// ✅ Safe pattern: typed operations with validated parameters
const tools = {
get_customer_orders: async ({
customer_id,
limit = 10,
}: {
customer_id: string;
limit?: number;
}) => {
if (limit > 100) throw new Error('Maximum limit is 100');
return await readPool.query(
`SELECT id, status, total, created_at
FROM orders
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[customer_id, limit]
);
},
update_order_status: async ({
order_id,
status,
}: {
order_id: string;
status: 'processing' | 'shipped' | 'cancelled';
}) => {
const validTransitions: Record<string, string[]> = {
processing: ['shipped', 'cancelled'],
shipped: [],
cancelled: [],
};
const current = await readPool.queryOne(
'SELECT status FROM orders WHERE id = $1',
[order_id]
);
if (!validTransitions[current.status]?.includes(status)) {
throw new Error(`Invalid transition: ${current.status} → ${status}`);
}
return await writePool.query(
'UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2',
[status, order_id]
);
},
};
The get_customer_orders tool can only do exactly that. It can't filter by other fields. It can't delete. It can't touch other tables. The agent knows nothing about your database schema — it only knows the tools and their parameters.
Separate Read and Write Connections
A database connection has permissions at the database user level. If you use the same connection for everything, every tool has access to everything. The fix is straightforward: separate connections with separate permissions.
import { Pool } from 'pg';
// PostgreSQL user with SELECT-only on required tables
const readPool = new Pool({
connectionString: process.env.DATABASE_READONLY_URL,
max: 10,
});
// User with limited INSERT/UPDATE on specific tables
const writePool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 3, // fewer write connections = lower flood risk
});
In PostgreSQL, the read-only user is set up in one migration:
CREATE USER agent_readonly WITH PASSWORD '...';
GRANT SELECT ON orders, customers, products TO agent_readonly;
-- No GRANT INSERT, UPDATE, or DELETE
When the agent tries to operate outside its permissions, the database rejects it before execution. No extra logic needed in application code.
Audit Trail: Know What the Agent Did and When
This isn't just for debugging. It's for catching anomalous patterns before they become incidents.
async function auditedTool<T>(
toolName: string,
agentId: string,
sessionId: string,
params: unknown,
operation: () => Promise<T>
): Promise<T> {
const startTime = Date.now();
try {
const result = await operation();
await logAudit({
toolName,
agentId,
sessionId,
params,
success: true,
duration: Date.now() - startTime,
});
return result;
} catch (error) {
await logAudit({
toolName,
agentId,
sessionId,
params,
success: false,
error: (error as Error).message,
duration: Date.now() - startTime,
});
throw error;
}
}
With this in place, you can answer in seconds: how many write operations did the agent perform in the last 4 hours? Is there a session_id making 10x more queries than average? Is any tool consistently failing with specific parameter values?
Validate Impact Before Bulk Writes
A write operation without a scope restriction can affect millions of rows. Before executing any variable-scope mutation, estimate the impact:
const bulk_update_orders = async ({
filter,
new_status,
}: {
filter: { created_before: string };
new_status: string;
}) => {
// First: count how many records would be affected
const { count } = await readPool.queryOne<{ count: number }>(
'SELECT COUNT(*) AS count FROM orders WHERE created_at < $1',
[filter.created_before]
);
if (count > 500) {
throw new Error(
`Operation would affect ${count} records. Maximum is 500. Use a more specific filter.`
);
}
return await writePool.query(
'UPDATE orders SET status = $1 WHERE created_at < $2',
[new_status, filter.created_before]
);
};
This "count before execute" pattern matters most when the agent receives vague user instructions. The LLM doesn't know your data volume — but your tool can check.
The Difference Between a Fragile and a Robust Agent
In the AI integration projects we build for clients, agents have access to real production data. A security failure doesn't just break the application — it can permanently destroy data or leak sensitive information through agent context.
Implementing these patterns at the start of a project takes hours. Recovering from a production incident can take days or weeks, not counting the business impact.
90% of teams that come to us after an agent production failure had the same root cause: they gave the agent direct access to resources that should have been protected behind a layer of typed, audited tools.
If you're building an agent that touches your database, ask this question for every tool you define: what's the worst this function can do if it receives the most extreme possible parameters? If the answer includes deleting or mass-modifying records, you have a design problem to fix before your first deploy.
Building an AI agent that needs secure access to your company's data? Let's talk about getting the architecture right from the start.