Securing Your Database — Where Your Data Lives
Layer 8 in the HTTP Request Journey
The database is the most valuable asset in your application. That's where user data, transactions, passwords, documents live — everything that gives the application its value. And that's exactly why it's target number one.
The irony is that many applications secure every layer above, then approach the database naively — a simple connection string hardcoded in the code, a root-privileged user, no encryption, access from any IP. The rest of this article is about fixing that.
SQL Injection — Still Relevant After 25 Years
SQL injection has appeared on the OWASP Top 10 list since the first edition in 2003. Twenty-two years, tens of millions of articles, and it's still one of the most common attack vectors. Why?
Because it's intuitively hard to "see." When you write:
// BAD — classic SQL injection
const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;You see code that "just works." But an attacker sees something different. If they supply as the email:
admin' --The query becomes:
SELECT * FROM users WHERE email = 'admin' --' AND password = 'anything'-- is a comment in SQL. The password condition ceases to exist. The attacker logged in as admin without knowing the password.
A more destructive example — injection with DROP TABLE:
'; DROP TABLE users; --Parameterized Queries — The Only Correct Solution
// GOOD — parameterized query
const query = 'SELECT * FROM users WHERE email = $1 AND password_hash = $2';
const result = await db.query(query, [email, passwordHash]);The database treats $1 and $2 as data values, not as part of the SQL code. There's no way to "escape" the data boundary.
Most ORMs (Prisma, TypeORM, Sequelize, ActiveRecord, SQLAlchemy) use parameterized queries by default. But an ORM doesn't protect you automatically if you use raw queries — for example:
// Sequelize — raw query without parameterization → vulnerable
const users = await sequelize.query(
`SELECT * FROM users WHERE email = '${email}'`
);
// Sequelize — raw query with parameterization → safe
const users = await sequelize.query(
'SELECT * FROM users WHERE email = :email',
{ replacements: { email } }
);The rule: never interpolate variable values directly into SQL queries. Use placeholders.
NoSQL Injection — MongoDB Isn't Safe Either
SQL injection isn't limited to relational databases. MongoDB and other NoSQL databases have their own version of this problem.
Classic example with Express + Mongoose:
// BAD — attacker can inject a MongoDB operator
const user = await User.findOne({ email: req.body.email, password: req.body.password });If an attacker supplies as email:
{"$gt": ""}The query becomes:
User.findOne({ email: { "$gt": "" }, password: ... })$gt: "" matches any string — the attacker logged in without knowing any email.
The fix: validate and sanitize input before using it in a query. The express-mongo-sanitize library does this automatically, stripping $ operators from incoming data.
Principle of Least Privilege for the Database User
Your application should connect to the database using an account with minimal permissions. Not root, not admin, not postgres — only what's needed.
In practice, this means a separate account for the application:
-- PostgreSQL
CREATE USER app_user WITH PASSWORD 'securepassword';
-- Permissions only for required tables and operations
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE users TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE orders TO app_user;
GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO app_user;
-- No DROP, ALTER, CREATE rights
-- No access to other tables (system logs, other schemas)Why does this matter? Even if SQL injection is possible, the attacker is limited to what the database user can do. Without DROP TABLE they can't delete data. Without SELECT on sensitive tables they can't read data the application doesn't need to access.
Encryption at Rest
TLS encryption protects data in transit. Encryption at rest protects data written to disk.
If someone gains physical access to the server or takes a disk dump — without encryption at rest they have access to all data.
Options:
Database-level encryption:
- PostgreSQL: pgcrypto — encrypting specific columns
- MySQL: Transparent Data Encryption (TDE)
- MongoDB: Encrypted Storage Engine (Enterprise or Atlas)
Disk-level encryption:
- LUKS on Linux
- Encrypted storage in AWS RDS (one checkbox when creating the instance)
- Azure SQL Database Transparent Data Encryption — enabled by default
For most projects using a managed database (RDS, Cloud SQL, Supabase), encryption at rest is enabled by default or is a one-line option. There's no reason not to use it.
Encrypting sensitive columns:
Full database encryption protects against disk theft. But if someone gains database access through the application — they get everything decrypted. For particularly sensitive data (national ID numbers, payment card data, secrets) consider application-level encryption:
import crypto from 'crypto';
const ENCRYPTION_KEY = Buffer.from(process.env.FIELD_ENCRYPTION_KEY, 'hex'); // 32 bytes
const IV_LENGTH = 16;
function encrypt(text) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-cbc', ENCRYPTION_KEY, iv);
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}
function decrypt(text) {
const [ivHex, encryptedHex] = text.split(':');
const iv = Buffer.from(ivHex, 'hex');
const encrypted = Buffer.from(encryptedHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', ENCRYPTION_KEY, iv);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
}Store the encryption key separately from the database — in environment variables, AWS Secrets Manager, HashiCorp Vault.
Encrypting the Database Connection
Just as HTTPS encrypts the browser connection, the application's connection to the database should be encrypted — especially if the database is on a separate server.
// PostgreSQL with SSL
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: {
require: true,
rejectUnauthorized: true, // Verify the server certificate
ca: process.env.DB_SSL_CERT // CA certificate if self-signed
}
});In managed databases (RDS, Supabase, Neon), SSL is available by default and often required. Check the panel and set sslmode=require in the connection string.
Network Access — The Database Should Not Be Publicly Reachable
The database should not be accessible from the internet. Port 5432 (PostgreSQL), 3306 (MySQL), 27017 (MongoDB) should not be open externally.
Correct network configuration:
- Database in a private subnet (VPC private subnet)
- Application in the same network or via VPN
- External access only via bastion host or VPN — and only for administrators
In the cloud (AWS, GCP, Azure), Security Groups / Firewalls let you restrict database port access to only the application server's IP addresses. A few clicks that eliminate entire classes of attacks.
If you're using a managed database like Supabase or PlanetScale — check their documentation on IP allowlisting.
Connection Pooling and Security
Connection pooling (e.g. PgBouncer for PostgreSQL, ORM connection pools) reuses the same database connections instead of creating new ones for each request. Important for performance — but it has security implications too.
The main trap: session-level state. In PostgreSQL you can set session variables (SET LOCAL, SET SESSION). If a connection is shared and not properly reset, a variable set by one user can "leak" to the next.
Most good pool managers handle this automatically by resetting the session. Make sure yours does — read the documentation.
Data Leaks Through Query Logs
SQL query logs may contain parameter values — passwords, tokens, personal data. If these logs end up in a centralized logging system without proper access controls, you have a data leak.
What to do:
- Disable query logging in production or limit it to slow queries (slow query log)
- If you log, make sure parameters are masked
- Store database logs with restricted access
In PostgreSQL:
# postgresql.conf
log_min_duration_statement = 1000 # Only log queries slower than 1s
log_statement = 'none' # Don't log all queriesPassword Rotation and Credentials
Database passwords should be rotated regularly — especially if:
- An employee with credentials access leaves the company
- You suspect a compromise
- Your organization's security policy requires it
Tools like AWS Secrets Manager or HashiCorp Vault can automate rotation without application downtime.
Never hardcode connection strings with passwords in your code. Use environment variables or a secret manager:
# BAD — hardcoded in code
const db = new Pool({ password: 'mypassword123' });
# GOOD — from env
const db = new Pool({ connectionString: process.env.DATABASE_URL });Checklist
- All SQL queries use parameterization / prepared statements
- Raw queries in the ORM are also parameterized
- Application connects as a user with minimal privileges (not root/admin)
- Encryption at rest enabled (in managed database — check settings)
- Database connection over SSL/TLS with
rejectUnauthorized: true - Database port not accessible from the internet (private subnet, Security Group)
- Connection string in environment variables, not in code
- Slow query log instead of logging all queries
- Database password rotation as part of security policy
- Database backup — tested (see: Backups and Recovery)
Summary
The database is usually what attackers want most. Each technique below either makes it harder to reach, harder to exploit once reached, or limits the damage if something does go wrong.
| Technique | Protects against |
|---|---|
| Parameterized queries / prepared statements | SQL injection |
| Least-privilege DB user | Blast radius in case of compromise |
| Encryption at rest | Data exposure from physical or storage breach |
| DB connection over TLS | Interception of queries in transit |
| Private subnet + closed external port | Direct external access to the database |
| Slow query log (not all queries) | Sensitive data leakage in logs |
| Connection string in env vars | Credentials committed to source control |
Most of these are one-time configuration decisions, not ongoing work. The one that consistently makes the biggest difference: keeping the database on a private subnet with no publicly reachable port. If you're using a managed database (RDS, Cloud SQL, Supabase), all of these are options in the console. For what to do after something goes wrong — backups, recovery, incident response — see Backups and Recovery.
Sources: