Skip to main content
Blog

Server

Database security: from the port to field encryption

Keep the database off the internet; least-privilege users, TLS, backups, encryption of sensitive fields and auditing. A practical guide for PostgreSQL and MySQL.

· 11 min read

Photo: The National Archives (UK), CC BY 3.0 · Wikimedia Commons

The database is the most valuable part of a system. Users, payments, documents, personal data — it is all here. Even so, databases are often set up on the principle of "the app works, so everything is fine": default settings, a single root or postgres user, listening on every network interface.

In this article we look at the main layers of database protection: network, users and privileges, encrypted connections, encryption of sensitive fields, backups and auditing. Examples are for PostgreSQL and MySQL/MariaDB, with the application side shown in Laravel and Node.

What the problem is

The most common database mistakes:

  • The port is open to the internet. MySQL (3306), PostgreSQL (5432), Redis (6379) or MongoDB (27017) are visible from outside. This is a direct path for password guessing and for vulnerabilities in the database software itself.
  • The application connects as a superuser. If the app uses root or postgres, a single SQL injection may give control over the whole server: other databases, file reads, command execution.
  • Weak or reused passwords. The same password across several environments, a .env file left in git history.
  • Unencrypted connections. If the application and the database run on different servers, queries and results travel across the network in plain text.
  • Sensitive fields stored in the clear. Passport data, personal identification numbers, tokens stored as plain text. Any copy of the database is a full leak.
  • A database web interface is exposed. Tools like phpMyAdmin or Adminer sit at a public address.

Why it matters for the expertise

During the expertise the server is port-scanned from outside, so an open database port is found immediately. In the expertise of our own product, open ports were also a separate finding (there it was FTP and panel ports), see open ports and server hardening. In addition:

  • paths like /phpmyadmin, /adminer.php, /pma are checked by common names;
  • if an SQL injection is found, its impact depends on the database user's privileges — least privilege limits the damage;
  • A02 "Cryptographic Failures" in the OWASP Top 10 (2021) covers missing encryption of sensitive data at rest and in transit, and A05 "Security Misconfiguration" covers default settings.

Principle: only the application can see the database, the application can do only what it needs, and sensitive data stays unreadable even if it leaks from the database.

Check it yourself

Where the database listens

On the server:

sudo ss -tlnp | grep -E "3306|5432|6379|27017"

127.0.0.1 or a Unix socket is good. 0.0.0.0 or * means that, without a firewall, the database is reachable from outside.

From outside (from another machine, only against your own server):

nmap -Pn -p 3306,5432,6379,27017 your-server-ip

Users and privileges

PostgreSQL:

SELECT rolname, rolsuper, rolcreaterole, rolcreatedb FROM pg_roles WHERE rolcanlogin;

MySQL:

SELECT user, host FROM mysql.user;
SHOW GRANTS FOR 'app'@'localhost';

The application user must not have SUPER, FILE, GRANT OPTION or ALL PRIVILEGES ON *.*. A % in the host column means the user can connect from any address.

Web interfaces

for p in phpmyadmin pma adminer.php adminer phpMyAdmin; do
  printf "%s " "$p"; curl -s -o /dev/null -w "%{http_code}\n" "https://example.uz/$p/"
done

The fix

1. Bind the database to localhost

PostgreSQL postgresql.conf:

listen_addresses = 'localhost'

MySQL/MariaDB mysqld.cnf:

[mysqld]
bind-address = 127.0.0.1

Redis redis.conf: bind 127.0.0.1 ::1 and protected-mode yes, plus a password or ACL.

If the database runs on a separate server, it should listen only on the internal network and the firewall should allow only the application server's IP:

sudo ufw allow from 10.0.0.5 to any port 5432 proto tcp

In PostgreSQL, pg_hba.conf follows the same principle:

# TYPE  DATABASE  USER  ADDRESS        METHOD
local   appdb     app                  scram-sha-256
hostssl appdb     app   10.0.0.5/32    scram-sha-256

Do not use the trust method, and choose scram-sha-256 for password hashing (password_encryption = scram-sha-256).

2. Least-privilege users

Separate at least three roles: migrations (change the schema), the application (works with data only) and backups or reporting (read only).

PostgreSQL:

CREATE ROLE app LOGIN PASSWORD '...';
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app;
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;

MySQL:

CREATE USER 'app'@'localhost' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app'@'localhost';
CREATE USER 'report'@'localhost' IDENTIFIED BY '...';
GRANT SELECT ON appdb.* TO 'report'@'localhost';

Generate passwords with openssl rand -base64 24 and keep them only in .env or a secrets manager. Use a separate password for each environment (test, production). More: managing secrets and keys.

3. Encrypted connections (TLS)

If the database and the application share a server and connect over a Unix socket or 127.0.0.1, TLS is not required. If they are on different servers, it is mandatory.

On the PostgreSQL server, configure ssl = on and a certificate, and use hostssl in pg_hba.conf. Laravel config/database.php:

'pgsql' => [
    // ...
    'sslmode' => env('DB_SSLMODE', 'verify-full'),
],

In MySQL you can require a user to connect only over TLS:

ALTER USER 'app'@'10.0.0.5' REQUIRE SSL;

A Node.js (pg) example:

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: { ca: fs.readFileSync('/etc/ssl/db-ca.pem'), rejectUnauthorized: true },
});

rejectUnauthorized: false disables certificate verification and defeats the purpose of TLS.

4. Encrypting sensitive fields

Disk encryption protects you if the server is stolen, but not against data obtained through a database dump or SQL injection. Add application-level encryption for the most sensitive fields.

In Laravel, use the encrypted cast:

protected function casts(): array
{
    return [
        'passport_number' => 'encrypted',
        'api_secret' => 'encrypted',
    ];
}

If you need to search by an encrypted field, store a keyed hash (HMAC) in a separate column and search on that. Passwords, on the other hand, are not encrypted but hashed with bcrypt or argon2id.

Important: the encryption key (APP_KEY) is stored separately from the database. If the key and the database leak together, encryption will not help. Plan key rotation in advance as well.

5. Close web interfaces

If you need phpMyAdmin or Adminer, do not leave them at a public address: only via an SSH tunnel or an allowlist. Best of all, do not install them on the production server at all, and connect with a local client over an SSH tunnel when needed:

ssh -L 5432:127.0.0.1:5432 user@your-server

6. Backups and auditing

Database backups must be encrypted and stored elsewhere, and restores tested regularly — see backups and restore for details.

For auditing:

  • in PostgreSQL, log_connections = on, log_disconnections = on, and log_statement = 'ddl' for schema changes; the pgaudit extension for detailed auditing;
  • in MySQL, logging of failed connections, and an audit plugin in Percona or MariaDB;
  • at the application level, who viewed or changed a sensitive record (an audit table).

Logging every statement (log_statement = 'all') takes a lot of space and puts sensitive data into the logs — enable it only for temporary diagnostics.

7. Updates

The database software needs updating too. Versions past end of support no longer receive security fixes. Check the version (SELECT version();) and compare it with the vendor's support schedule.

On the application side

Database settings cannot fully close a bug in the application. The basic rules:

  • parameterised queries only, no concatenating values into whereRaw or raw SQL;
  • an allowlist for orderBy and column names;
  • no SQL queries or database details in error messages (APP_DEBUG=false);
  • never put a connection string into a front-end or mobile app.

Short checklist

  • Database ports are closed to the internet; the database listens on 127.0.0.1 or the internal network
  • pg_hba.conf / MySQL host allow only the necessary addresses, no trust
  • The application connects as a least-privilege user, not a superuser
  • Separate roles for migrations, the application and reporting
  • Strong passwords, different for each environment; .env is not in git
  • TLS with certificate verification between separate servers
  • The most sensitive fields are encrypted at application level, the key is kept separately
  • No phpMyAdmin/Adminer at a public address
  • Encrypted backups and restore tests
  • Connections and schema changes are logged; the database version is supported

Summary

Database protection consists of a few simple layers: hide it from the network, restrict privileges, encrypt connections and sensitive fields, back it up and monitor it. Each layer limits the damage if another one fails. In our process, the Claude AI agent reviews the database configuration, pg_hba.conf, user privileges and the queries in the application code, and the expert applies the changes in a safe order without stopping the running system.

If you want to know whether your database is ready for the expertise, request a security scan. We will help you close the gaps before the official check.