Scope the MySQL user first: the newsletter DDL as a security boundary on cPanel

The first code-bearing commit in Phase 8 wasn’t the PHP. It was a 190-line DDL file.

cpanel-backend/schema.sql defines the newsletter schema for the captainrandomco_newsletter database. The comments are substantive throughout — enough that the file doubles as design documentation. The last act in applying it was locking the cPanel MySQL user to that database alone. Everything else — the subscriber pipeline, the confirmation flow, the unsubscribe handler — sits on top of that privilege boundary.

That order matters. The DDL first, the app second.

What shared hosting actually gives you

cPanel is not a dedicated server. You don’t write iptables rules. You don’t control which ports are open, which processes share your host, or what the neighbouring tenant is running. The infrastructure is managed; you get a slice of it.

What you do control:

  • Which databases exist under your account
  • Which MySQL users exist and what privileges they hold
  • What PHP runs and under what constraints (.htaccess, open_basedir)
  • What your DDL says the schema is

Most cPanel newsletter tutorials create one MySQL user with broad privileges and stop thinking about it. The upside is simplicity. The problem is scope: that user can read your blog table, your analytics table, your client records, whatever else the application has accumulated. A compromised endpoint inherits all of it.

The newsletter backend runs under a MySQL user scoped only to captainrandomco_newsletter. That is the entire scope.

The DDL as the security document

schema.sql is 190 lines. It is not a migration file you apply once and discard. The comments explain why a column exists, what invariant it maintains, what the application will do with it. The schema doubles as design documentation because there is no ORM, no framework, no abstraction between the DDL and runtime behaviour.

The privilege grant belongs in that document. The MySQL user the PHP endpoints connect as is defined in the same commit that creates the tables. That co-location is deliberate: the privileges are a property of the schema design, not a separate ops step to remember later.

Privileges the app never holds cannot be abused by a compromised endpoint. That sounds obvious. On a shared host it is easy to stop short of acting on it — creating a user, giving it access to the wrong database, leaving it with broad rights because you will sort it out after the feature ships. After is when subscribe.php gets hit with a malformed payload.

What the endpoint can actually do

Phase 8 shipped three endpoints. subscribe.php is the most complex. The handler is ~225 lines and runs in eight stages before it touches the database: method check, Origin check, per-IP rate limit, honeypot field, time-trap, email validation, DB upsert, confirmation mail.

Each stage is a filter. By the time execution reaches the upsert, the request has been verified to be a POST from the correct origin, from an IP that has not hit the rate limit, without a honeypot value, submitted within a plausible human time window, carrying a valid email address.

The response is enumeration-resistant. Whether the email is new, already subscribed, or pending confirmation, the HTTP response looks the same. The endpoint does not tell the caller which state applied. An attacker probing the list cannot distinguish a cold address from an active subscriber.

The MySQL user receives that upsert. It can execute it. That is the full extent of what it can do. It cannot SELECT from a table outside the schema. It cannot reach a different database. The privilege boundary is a hard floor, independent of whether the PHP above it has a bug.

That independence is the point. Defence in depth means the layers are genuinely independent. If the Origin check has a bypass, the rate limiter still runs. If the rate limiter has a bug, the email validation still runs. If all of those fail and a malicious payload reaches the database layer, the scoped MySQL user cannot act outside the newsletter schema. The blast radius is defined at the privilege level, not at the application level.

Tokens and the GET-then-POST pattern

confirm.php and unsubscribe.php follow a different pattern. Both use a GET interstitial before the state-changing POST.

The reason is token exposure. Confirmation tokens travel in email links. Email links are clicked in browsers. Browsers record URLs in history. Servers record them in access logs. Proxies record them in Referer headers. A token in any of those records can be replayed.

The GET request renders an interstitial page. The interstitial holds the token in a hidden form field. The user submits the form. The POST carries the token in the request body, not the URL. It never enters browser history, never appears in Referer, never lands in the server’s access log URL column.

This is an established pattern that most newsletter implementations skip because it requires two round-trips. The tradeoff: a marginally higher confirmation dropout rate against token exposure in logs and history. On a shared host where log access is broader than on a self-managed VPS, that tradeoff tilts toward the two-step pattern.

Again: even if the token handling had a flaw, the scoped MySQL user limits what any exploit could reach. The application-layer protections and the DB-layer privilege scope are not alternatives. Both run.

The scaffold underneath

Phase 8’s backend is ~600 lines across 15 files in cpanel-backend/. No framework. No Composer. No autoloader. Six small PHP classes loaded via plain require_once. An .htaccess to control what the web server exposes. An ops runbook.

Staying zero-dependency was a deliberate choice. Composer adds a dependency graph to audit and update. A framework adds conventions to follow, abstractions to understand, upgrade paths to track. For six small classes on shared hosting, the maintenance cost of a framework exceeds the value. The classes are small enough to read end-to-end.

The cost is that there are no batteries. No ORM. No request object. No middleware pipeline. Each of the eight subscribe stages is explicit. That explicitness is legible: the security properties of the pipeline are in the code, not inferred from what a framework does by default.

What cPanel forces you to get right

On a self-managed VPS you can add a firewall rule, rotate a key, restrict a port. The temptation to compensate for a privilege design shortcut with an infrastructure control is always available. On cPanel it is not. The privileges and the DDL are what you have.

That constraint is also a forcing function. Because there is nothing else to fall back on, the schema has to be right. The privilege scope has to be right. The three endpoints have to handle their own validation rather than relying on network controls upstream.

schema.sql is 190 lines. Read it and you know exactly what the application can do to the database. The spec, the design document, and the security boundary are the same file.

All writing