The subscribe endpoint runs to roughly 225 lines. Eight stages, sequential, no magic: method check, Origin check, per-IP rate limit, honeypot and time-trap, email validation, DB upsert, confirmation mail send, then a response that looks identical whether the address was new or already existed. Read that file top to bottom and you have the full attack surface for the subscription pipeline. Nothing happens outside it that you can’t account for by reading it.
That’s the trade that came with choosing no framework.
The stack decision
The backend runs on a shared cPanel host. Two options: pull in a framework via Composer, or write six PHP classes loaded with require_once. A framework brings routing, an ORM, middleware, validation helpers, mailer abstractions. It also brings a dependency graph. That graph is a separate audit surface with its own release cadence.
cpanel-backend/ is roughly 600 lines across 15 files. Six classes. No autoloader. No third-party code. The entire backend is one directory. That constraint changes what “security audit” means.
With a framework, auditing security means reading documentation, checking CVE feeds, running composer audit, and trusting that the abstractions behave as described. With 600 lines you wrote yourself, auditing security means reading 600 lines. Both are valid. They are different threat models.
Building the privilege boundary first
The schema came before any endpoint code: schema.sql, 190 lines of DDL. The file is commented heavily enough that it doubles as design documentation — if you read nothing else, the schema tells you what data the system holds and why. The database user for the newsletter backend has scoped privileges: INSERT and SELECT on the tables it needs, nothing more. That was P8-4. No code ran against the live database yet. The privilege boundary was drawn first.
P8-5 built the six library classes and the endpoint shells, plus the .htaccess configuration that locks down direct HTTP access to the class files. They load via require_once from the endpoint scripts. They are never exposed as HTTP resources. The only entry points to the PHP code are the endpoint scripts.
The eight stages of subscribe.php
P8-6 replaced the stub with the full pipeline. Top to bottom:
- Method check. POST only. Anything else returns 405.
- Origin check. The Origin header must appear in the allow-list, otherwise the request dies here. Cross-origin submissions from arbitrary pages never reach validation.
- Per-IP rate limit. Rate state is persisted in the database, so repeated submissions from the same IP hit a ceiling before they touch email validation.
- Honeypot and time-trap. A hidden field that legitimate users never see. Automated submissions that fill it are rejected on sight. The time-trap catches submissions that arrive faster than a human could plausibly type.
- Email validation. Format check. Straightforward.
- DB upsert. The address is written or updated. The operation is identical whether the address is new or already present.
- Confirmation mail send. A token is generated, stored, and mailed. The token is the only credential for the confirmation step.
- Response. Identical body regardless of whether the address was new or already subscribed. Enumeration-resistant.
That list is the threat model for the subscription path. Spam bots, scraper submissions, cross-origin attacks, address enumeration, confirmation token exposure — each has a line in the file that handles it.
Token hygiene: GET interstitial, POST mutation
P8-7 built the real confirm.php and unsubscribe.php handlers. The design follows one rule from the HTTP spec: GET requests must be safe. A GET that mutates state puts the token into browser history, server access logs, and the Referer header of any subsequent request to a third-party resource on that page. Those are places a confirmation token must never appear.
The pattern: the email link carries the token as a GET parameter, because that is how email links work. The GET renders an interstitial page with a confirmation button. Clicking it fires the POST, which performs the mutation and consumes the token. The token is never consumed on GET.
The same logic applies to unsubscribe.php. GET renders the interstitial. POST performs the opt-out and consumes the token.
This is not a clever pattern. It is an old one, enforced by convention in most frameworks and enforced by memory in hand-written code. The cost of no framework, here, is that you have to remember it.
What the directory looks like
cpanel-backend/ is one directory. The library classes each have one job. The endpoint scripts load only what they need. There is no routing layer in PHP. Request routing happens at the server level, outside the application code. There is no ORM: SQL is parameterised and present in the files that use it. There is no session handling. The subscription flow is stateless. The token is the only state and it lives in the database.
You can read the whole thing in one sitting. That sentence is the point.
The trade-offs
The ecosystem cost is real. Implementations I would have pulled from a library took time to write. The per-IP rate limiter has a known blind spot: it keys on the originating IP, which does not handle shared egress or VPN rotation well. That limitation is in the code. There is nowhere else for it to go.
A framework would have provided more sophisticated implementations. It would also have brought a vendor/ directory with its own audit surface. At this scale (a personal site, a low-volume newsletter) the trade is defensible. The threat model is spam bots and casual probing. The six classes are sufficient.
Out of scope: email deliverability. SPF, DKIM, and DMARC are DNS and cPanel configuration, outside this codebase. The subscription backend is responsible for the subscription pipeline. Deliverability is handled at the infrastructure layer.
The readable threat model
The endpoint scripts are the full attack surface. subscribe.php is the longest. confirm.php and unsubscribe.php are shorter, but the GET interstitial adds a render step while the core logic stays compact. Read those files and you have a complete picture of what the backend accepts, what it validates, what it refuses, and what it trusts.
The schema adds the privilege model. The .htaccess configuration adds the access control layer. Together: three artefacts, all in one directory, readable in an afternoon.
A framework distributes that picture across documentation, configuration files, middleware chains, and third-party source. Not wrong, just a different kind of legibility that requires different tooling to inspect. The hand-written version collapses the two: the threat model is the code, because no abstraction layer sits between them.



