Plan for a database host that refuses your triggers

  • databases
  • php
  • security

A while back I wrote about making an audit trail append-only: block UPDATE and DELETE on the evidence tables with database triggers, give the app a user that can only insert and select, and hash-chain the rows so tampering shows up. That design is sound. Then I went to deploy a side project on inexpensive shared hosting and found out the database would let me do almost none of it.

The host’s MySQL didn’t allow CREATE TRIGGER at all on the default plan. GRANT, REVOKE, and CREATE USER weren’t available either, and privileges in the control panel were set per database, not per table. So the trigger migration would have failed halfway through migrate, and the “insert-only on audit tables” user simply couldn’t exist.

None of that is unusual. It’s the normal shape of cheap managed databases, and it’s worth planning for before deploy day instead of discovering it at 11pm with a half-applied migration.

Find out what the host actually allows

Marketing pages say “MySQL 8 included.” They don’t say which statements you can run. Before I pick a host for anything that leans on database features, I now go through a short list and look for the answer in the host’s own documentation:

  • Can I create triggers, stored procedures, and functions? Often the answer is “you can use them but not create them,” which in practice means no.
  • Do triggers need a DEFINER clause, or must they not have one? Some hosts allow triggers only without DEFINER, because setting it requires SUPER.
  • Is binary logging on, and can I set log_bin_trust_function_creators? With binary logging on and no SUPER, MySQL can refuse to create triggers even when you otherwise have the privilege.
  • Are privileges per table or only per database? This decides whether a least-privilege user is possible at all.
  • What’s the default character set? Some still default new databases to utf8mb3. Most frameworks create tables as utf8mb4 explicitly, but check the schema after the first migration anyway.

If the docs can’t answer a question, I mark it as unknown and test it on a throwaway database. A single CREATE TRIGGER against a scratch table settles it in a minute.

Make the migration honest about what it got

The worst outcome is a migration that dies partway. The next worst is one that quietly skips the triggers, so everyone assumes the protection exists. I want a third option: try to create them, and if the server refuses, record that fact somewhere the app can read.

Here’s the shape of it as a Laravel migration. The idea carries to any framework.

public function up(): void
{
    if (DB::getDriverName() !== 'mysql') {
        return; // tests on SQLite rely on the app-level checks
    }

    try {
        foreach (['audit_events', 'acknowledgments'] as $table) {
            foreach (['UPDATE', 'DELETE'] as $op) {
                $name = "{$table}_no_" . strtolower($op);
                DB::unprepared("DROP TRIGGER IF EXISTS {$name}");
                DB::unprepared("
                    CREATE TRIGGER {$name} BEFORE {$op} ON {$table}
                    FOR EACH ROW
                    SIGNAL SQLSTATE '45000'
                    SET MESSAGE_TEXT = '{$table} is append-only'
                ");
            }
        }
        $status = 'present';
    } catch (QueryException $e) {
        Log::warning('Append-only triggers not created', ['error' => $e->getMessage()]);
        $status = 'absent';
    }

    DB::table('system_capabilities')->updateOrInsert(
        ['name' => 'append_only_triggers'],
        ['value' => $status, 'updated_at' => now()],
    );
}

A few details matter here:

  • No DEFINER clause. Leaving it out means the trigger runs as the user who created it, which is what restricted hosts usually require.
  • Drop before create. Rerunning the migration after a partial failure shouldn’t trip over a trigger that already exists.
  • Catch only the database error. I catch QueryException, not everything, so a typo in my own code still fails loudly.
  • Record the result. A row saying absent turns a silent gap into something the app, an admin page, or a health check can report.

You might worry this makes a security control optional. It does, and that’s the point of being explicit. If the protection is required for a given deployment, the health check should fail when the row says absent, and you shouldn’t put real data in until it says present.

Keep a check that doesn’t need the database’s help

Triggers prevent tampering. A hash chain detects it. On a host without triggers, detection is what you have left, so it has to stand on its own:

  • Every row stores a hash of its contents plus the previous row’s hash. Change a row and every hash after it stops matching.
  • A verify command walks the chain and reports the first break. Run it on a schedule and in your health check, not only when someone is suspicious.
  • Export the chain head somewhere the database credential can’t reach. This is the part people skip. Someone with full write access can rewrite a row and then recompute every hash after it, and the chain will verify cleanly. If the latest hash per tenant also goes out in a daily email, a report file in off-site backups, or anywhere else outside the database, a recomputed chain no longer matches what you published.

That last step is cheap and closes most of the gap between “the database enforces it” and “we’d notice if it happened.”

The tradeoffs

App-level immutability on a cheap host is weaker than real triggers and real grants. A stolen database password can still change rows; you just find out afterward instead of being stopped up front. For a demo or a small pilot with no sensitive data, I think that’s an acceptable trade for a hosting bill near zero, as long as it’s written down.

The options, roughly in order of cost:

  1. Stay on the cheap plan with tolerant migrations, the hash chain, and an exported chain head.
  2. Pay for the host’s upgraded database tier if it allows triggers. You often still won’t get per-table grants, but triggers plus the chain cover most of the risk.
  3. Use a managed database elsewhere or a server you control when you need the full design: triggers, a least-privilege user, and the chain.

What I’d avoid is the path I almost took: writing the full design, assuming the host would run it, and learning otherwise during the first deploy. Ask the boring questions first, make the migration tell you what it actually did, and keep one check that works no matter where the database lives.