Most teams assume "the app is authenticated, so the DB is safe." That's a dangerous assumption. If an attacker compromises your application (via RCE, SSRF, SQL injection, or a leaked admin panel), your database is the next target. Access controls are your last line of defense between a compromised app and full data exfiltration.
Mindset shift: Ask yourself, "If an attacker has shell access to my app server, how much of my database can they still reach?"
Your application should never connect to the database as root or any admin account. Use separate database users with narrowly scoped permissions:
-- Create role-specific users (PostgreSQL/MySQL examples)
CREATE ROLE app_read_only;
CREATE ROLE app_write_user;
GRANT SELECT ON users, orders, products TO app_read_only;
GRANT SELECT, INSERT, UPDATE ON users, orders TO app_write_user;
-- NEVER grant DROP, TRUNCATE, or ALTER to app usersThis limits blast radius: if your app is compromised, the attacker can't drop tables or dump the entire schema.
Don't use the same DB credentials for:
Each trust level should have distinct credentials. If your reporting module is vulnerable, it shouldn't have write access to user data.
Hardcoded connection strings in source code are a common leak vector (GitHub repos, Docker images, logs). Instead:
// Bad: hardcoded credentials
$pdo = new PDO('mysql:host=db;dbname=app', 'root', 'SuperSecret123');
// Good: from environment + secrets manager
$dsn = getenv('DB_CONNECTION');
$user = getenv('DB_USERNAME');
$pass = getenv('DB_PASSWORD'); // fetched from vault at runtime
$pdo = new PDO($dsn, $user, $pass);Even with access controls, SQL injection can bypass your application logic. Always use parameterized queries:
// Vulnerable
$query = "SELECT * FROM users WHERE email = '$email'";
// Secure
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);This prevents attackers from injecting malicious SQL even if they control input.
Stored procedures let you:
-- Create stored procedure
CREATE PROCEDURE create_user(IN email VARCHAR(255), IN pass_hash VARCHAR(255))
BEGIN INSERT INTO users (email, password_hash, created_at) VALUES (email, pass_hash, NOW());
END;
-- Grant execute only
GRANT EXECUTE ON PROCEDURE create_user TO app_write_user;Your app user can now create users without direct INSERT permissions on the table.
Leaving connections open increases attack surface:
max_connections limits at the database level// Ensure connection is closed
$pdo = null; // or use try-finally with explicit closeBeyond application-level controls, harden the database itself:
root, sa, postgres default passwords-- Example: disable dangerous MySQL features
SET GLOBAL local_infile = 0;
SET GLOBAL secure_file_priv = '/var/lib/mysql-files';Access controls are useless if you don't know when they're bypassed. Implement:
SELECT * FROM users at 3 AM)GRANT, ALTER USER, or CREATE ROLE eventsSELECT or COPY operations-- PostgreSQL audit extension example
CREATE EXTENSION pgaudit;
ALTER SYSTEM SET pgaudit.log = 'write, ddl';Integrate these logs with your SIEM or monitoring stack (e.g., Grafana, ELK, Splunk).
ORMs help but don't replace access controls. They can still execute arbitrary queries if misused. Always layer database-level restrictions on top.
Firewalls don't stop compromised apps, insider threats, or misconfigured cloud security groups. Defense in depth is essential.
Minimal impact. Properly scoped permissions and connection pooling actually improve performance by reducing attack surface and resource contention.
Database access controls are your safety net when everything else fails. They enforce least privilege, limit blast radius, and buy you time to detect and respond to breaches. Like encryption, they're not optional—they're foundational.
Next up: Encrypting your database—because even with perfect access controls, you need to assume your data might be exfiltrated.