Cyber Security

Least-Privilege MariaDB Accounts for Small PHP Applications - Let the App Use Only What It Needs

Least-Privilege MariaDB Accounts for Small PHP Applications - Let the App Use Only What It Needs

A small PHP application may need only four ordinary database actions: read a row, insert one, update it, and sometimes delete it. Yet its connection can quietly use an account able to alter tables, create users, or reach every database on the server. The application still works, so the mismatch stays invisible until a bug, leaked credential, or injection flaw turns unnecessary permission into additional damage.

The useful question is not, “Which grant makes the error disappear?” It is, “What is the narrowest database authority this process needs to perform its documented job?” This article builds a review method around that question for a small PHP and MariaDB application. The SQL uses a fictional notes service; it is an example to adapt after inspecting an application's queries, not a claim about the configuration of this website.

Least privilege limits authority, not vulnerability

The NIST glossary defines least privilege as restricting a user, or a process acting for a user, to the minimum access needed for its assigned function. Applied to a database connection, the PHP process should receive enough permission for normal requests and no more.

This is containment, not immunity. If an attacker finds SQL injection, least privilege does not repair the vulnerable query. If the application may legitimately read all customer email addresses, its runtime account cannot make those addresses unreadable to a compromised application. What narrower grants can do is remove unrelated capabilities: a read-only endpoint need not delete rows, a normal request need not change the schema, and one application need not open another application's database.

The distinction matters because “not root” is only a starting point. An account with ALL PRIVILEGES on one application database is narrower than a server administrator, but it may still hold CREATE, ALTER, and DROP powers that routine web requests never use.

Inventory behavior before writing a GRANT

A privilege list copied from a tutorial is guesswork. Begin with the application's actual paths: controllers, repositories, queue workers, scheduled jobs, and migration files. Group each database operation by the process that performs it. A modest inventory could look like this:

ProcessExpected workLikely data privilegesSchema changes
Public web runtimeList and view published notesSELECTNone
Authenticated web runtimeCreate and edit notesSELECT, INSERT, UPDATE; DELETE only if the product truly deletesNone
Deployment migrationApply reviewed schema revisionsDepends on migrationSelected DDL privileges for the deployment window
Human administrationManage accounts and exceptional maintenanceTask-dependentTask-dependent

“Likely” is deliberate. A search feature may use a view; a queue may update status rows; an upload flow may write metadata; a soft-delete design may use UPDATE instead of DELETE. Static code review finds much of this, while staging tests reveal paths that were missed. Neither method alone proves completeness.

Do not infer a permanent privilege from a one-time installation step. If setup creates tables and the application then spends months serving requests, schema creation belongs to deployment, not to every request.

Give different jobs different identities

The OWASP Database Security Cheat Sheet recommends a separate account for each application or service, no built-in administrative account, access only from allowed hosts, and only the necessary databases and permissions. A practical extension is to separate identities by operational role:

  • Runtime account: used by PHP-FPM and ordinary background jobs; normally limited to application data operations.
  • Migration account: used by a controlled deployment step, not stored in the web runtime's environment.
  • Administrative account: used interactively for account management or exceptional maintenance, never embedded in application configuration.

This division makes the boundary enforceable. A PHP bug cannot use a migration permission that the PHP process never receives. It also makes logs and credential rotation easier to reason about. The cost is operational: deployments need a distinct secret and a documented procedure. For a tiny application, that may feel heavier than one all-purpose account, but the alternative transfers deployment authority into every web request.

Remember that a MariaDB account includes a host

MariaDB identifies an account by both user name and host, written as 'user'@'host'. Its CREATE USER documentation says that omitting the host implies %, a wildcard. That convenience is broader than a specific application host.

Connection transport also matters. On Linux, MariaDB documents 'user'@'localhost' as matching local Unix-socket connections; a TCP connection to 127.0.0.1 is not the same match. Before creating an account, inspect the PHP DSN and determine whether the application uses a socket, loopback TCP, or a separate database host. Restrict the account to that real path rather than changing it to % when authentication fails.

Host scoping is one layer, not a firewall replacement. OWASP separately recommends limiting database network exposure to the few hosts that need it. The account rule and the network rule should agree.

Build an explicit runtime grant

MariaDB's documentation states that CREATE USER creates an account with no privileges. That is a useful blank page. Suppose the fictional notes_app runtime uses a local socket and its reviewed paths need four data operations across that database:

CREATE USER 'notes_runtime'@'localhost'
  IDENTIFIED BY 'REPLACE_WITH_A_GENERATED_SECRET';

GRANT SELECT, INSERT, UPDATE, DELETE
  ON `notes_app`.*
  TO 'notes_runtime'@'localhost';

Run account-management statements through an authorized administrative connection, not through the web application. The placeholder is not a password recommendation. Generate and deliver the real credential through the deployment's secret-handling process, keep it out of source control and the public web root, and avoid exposing it in shell history or documentation.

The MariaDB GRANT reference distinguishes global, database, table, column, and routine scopes. Here, notes_app.* means all objects in that database, including applicable future objects. It does not mean every database on the server. Even so, the listed actions must match the inventory. Omit DELETE if the runtime never deletes. A publishing frontend that only reads could receive:

GRANT SELECT
  ON `notes_app`.*
  TO 'notes_reader'@'localhost';

More granular is not automatically better. Table-level grants can prevent a public reader from opening private account tables:

GRANT SELECT
  ON `notes_app`.`published_notes`
  TO 'notes_reader'@'localhost';

But dozens of fragile table grants can break every schema change and become harder to audit than a well-chosen database-level grant. Choose the narrowest scope the team can keep accurate. Views or routines may offer a cleaner interface in some designs, but they introduce their own ownership and execution-context questions and are outside this basic pattern.

Keep schema authority out of normal requests

A runtime account usually has no reason to hold CREATE, ALTER, DROP, CREATE USER, FILE, or GRANT OPTION. The exact migration permissions are not universal: adding an index, creating a table, and replacing a view are different operations. Derive a migration account's grants from reviewed migration files and the deployment strategy, then keep that credential unavailable to PHP-FPM.

There is also a reliability tradeoff. Temporarily granting and revoking DDL for every deployment can be precise but easy to perform incorrectly. A dedicated migration account with a protected secret may be simpler. Either design is preferable to silently giving the runtime account every database privilege. Least privilege has to remain operable during a failed deployment, or pressure will eventually produce an emergency GRANT ALL.

Verify both permission and denial

Configuration text is not enough. MariaDB provides SHOW GRANTS to list the grants recorded for an account:

SHOW GRANTS FOR 'notes_runtime'@'localhost';

Read every line, paying attention to global grants, broader database grants, roles, and the exact host component. A narrow table grant does not help if another line already grants broad authority.

Then test behavior in staging through the same connection method and configuration the application will use. Positive tests should exercise every normal path. Negative tests should prove that prohibited actions fail: for example, the runtime account must not create or alter a disposable staging table and must not read an unrelated database. Never perform a destructive denial test against production data.

A useful acceptance rule is asymmetric: all documented application paths succeed, while representative out-of-scope actions fail. An error during an expected path means the inventory or architecture needs revision. It does not automatically mean the account should receive ALL PRIVILEGES.

Roll out without guessing under pressure

  1. Create the new account and explicit grants without changing the existing runtime.
  2. Test it in staging, including queues, scheduled jobs, maintenance screens, and uncommon write paths.
  3. Deploy the new credential through the normal secret mechanism.
  4. Replace existing database connections so the application actually authenticates as the new account.
  5. Observe denied-operation errors and application health during a bounded rollout.
  6. Only after confidence is established, remove the old runtime credential or revoke its obsolete grants.

MariaDB's REVOKE documentation warns, in effect, that removing one grant may not remove authority supplied by a broader grant. After any revocation, inspect effective grants again and repeat the denial tests. Also plan rollback before rollout: know which specific permission restores a missed path rather than reaching for unrestricted access.

Privileges drift as software changes. A new export job may need reads from another table; an abandoned feature may leave DELETE behind. Review grants alongside schema migrations and periodically compare them with current query paths. The cadence should follow how often the application changes, not an arbitrary universal interval.

Know what this boundary cannot do

  • It does not replace parameterized queries or other SQL-injection defenses.
  • It does not enforce which application user may access which row; that remains an application authorization problem unless a separately designed database mechanism does it.
  • It cannot hide data the runtime account must legitimately read.
  • It does not protect an exposed database password; credential storage, rotation, and network protection still matter.
  • It does not replace backups, restore tests, patching, monitoring, or audit logs.

These limits do not make least privilege cosmetic. They define its honest purpose: reducing the authority available when another control fails.

Conclusion

A database account should describe a job. For a small PHP runtime, that job is usually ordinary data access inside one application database, from one expected connection path. Schema migration and human administration are different jobs and deserve different identities.

The strongest workflow is also the least dramatic: inventory real queries, create an unprivileged account, add explicit grants, verify the recorded configuration, prove expected operations succeed, and prove representative forbidden operations fail. Revisit that evidence when the application changes. The goal is not to produce the smallest possible grant on paper, but the smallest grant that remains correct, testable, and maintainable.

References