The page editor is an optional plugin that adds a password-protected editor to the bottom of any page when ?editor=yourtoken is appended to the URL. It lets you edit page content and metadata, and create new pages, directly in the browser — with no database and no permanently exposed admin panel.
An optional second factor is also available: a six-digit code from an authenticator app, on top of the password. It is off by default and requires no third-party service.
When $editorEnabled is set to false, zero editor code runs anywhere on the site. It is completely inert.
Installation
The editor plugin ships as six PHP files and an .htaccess file, all inside plugins/editor/. They are included in the framework repository. No additional setup is needed beyond configuration.
| File | Purpose |
|---|---|
editor.php |
The editor interface and login form. |
editor-auth.php |
Authentication, sessions, CSRF tokens and the IP lockout. |
editor-save.php |
The endpoint that writes pages to disk. |
editor-totp.php |
Two-factor code generation and verification. Inert unless you switch it on. |
editor-totp-setup.php |
The two-factor enrolment screen. |
editor-qr.php |
Draws the enrolment QR code. Optional — see below. |
.htaccess |
Blocks direct web access to the plugin's .json state files. |
The plugin also expects config/.htaccess to be present, denying web access to the configuration directory. This ships with the framework and matters more than usual if you enable two-factor — see Keeping the secret safe.
plugins/editor/ must be writable by the user PHP runs as. The editor keeps two small state files there and will refuse to run if it cannot write them.
Configuration
All editor settings live in config/config.php inside the if ($loadplugins == true) block. There are five values:
$editorEnabled = true; $editorToken = 'your-random-token'; $editorPasswordHash = '$2y$10$...'; $editorTotpSecret = ''; $editorSessionTimeout = 1800;
$editorTotpSecret is optional. Left as an empty string, two-factor is off and the editor behaves exactly as it always has — no code field on the login form and nothing else to configure.
Generating a Password Hash
The editor uses a bcrypt password hash — you never store the plaintext password anywhere. To generate a hash:
Step 1. Create a new file anywhere in your site root — for example hash.php — containing exactly this:
<?php echo password_hash('yourpassword', PASSWORD_BCRYPT); ?>
Step 2. Visit that file in your browser: yoursite.com/hash.php
You will see a long string starting with $2y$10$ — something like:
$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
Step 3. Copy that entire string and paste it as the value of $editorPasswordHash in config.php.
Step 4. Delete hash.php immediately. Do not leave it on your server.
If you ever want to change your password, repeat this process with the new password and replace the hash in config.php.
Setting the URL Token
The $editorToken is a secret string that must be present in the URL for the editor to appear at all. Anyone who doesn't know the token will see nothing — not even a login form. This keeps the editor's existence hidden from casual probing.
Choose something long and random. The easiest way is to generate one at the command line:
php -r "echo bin2hex(random_bytes(16));"
This produces a 32-character hex string like a3f8c2d1e4b7906f2a1d3c5e7b9f0d2e. Paste that as your token:
$editorToken = 'a3f8c2d1e4b7906f2a1d3c5e7b9f0d2e';
Alternatively, make up any string you like — just keep it long (16+ characters) and don't reuse a password or other credential.
Two-Factor Authentication
Setting $editorTotpSecret adds a six-digit code to the login form, alongside your password. The code comes from an authenticator app on your phone — Google Authenticator, Aegis, 1Password, Ente, or any other TOTP app.
It is entirely optional. Leave the value as an empty string and nothing changes.
How it works
No third-party service is involved at any point, and nothing is exchanged over the network. Your authenticator app is an offline calculator: it holds a secret and reads the clock. The server holds the same secret and reads its own clock. Both compute the same number independently, and the server compares what you typed against what it computed.
The maths is RFC 6238. Divide the current Unix timestamp by 30 to get a counter, HMAC-SHA1 it with the secret, then truncate the result to six digits. PHP ships every piece needed, so the implementation has no dependencies, no build step and no external calls.
Turning it on
Step 1. Log into the editor as normal, with just your password.
Step 2. Click Two-factor: off in the editor toolbar, or go directly to:
https://yoursite.com/about?editor=yourtoken&totp=setup
The enrolment screen is only reachable once you are already logged in, so your password remains the gate even before a second factor exists.
Step 3. Scan the QR code with your authenticator app, or type the key in by hand — it is shown in groups of four to make that easier. The app will use six digits, a 30-second period and SHA1, which are the defaults everywhere, so you should not need to change any settings.
Step 4. Type the code your app is showing into the test box and press Test code. This only checks the code — it is not used up, and you can test as many times as you like. Do not skip this step. It is much easier to sort out a mismatch now than after you have committed the secret and logged out.
Step 5. Copy the line the screen prints and paste it into config/config.php:
$editorTotpSecret = 'LL2ITCZZLBF4UVYMCOQDTYXUJIFPCNCD';
Step 6. Log out and back in. You will be asked for a code from now on.
The enrolment screen never writes to config.php — it prints the line and you paste it. The editor does not need write access to your configuration.
Logging in with a code
The login form gains a second field next to the password box. Enter your password and the six digits your app is currently showing.
Codes are accepted within 30 seconds either side of the server's clock, which absorbs the usual drift on shared hosting. A code that has been used successfully cannot be used again, even while it is still on screen, so watching someone log in over their shoulder does not help you.
Failed codes count towards the same lockout as failed passwords: five failures from one IP disables the login form for 15 minutes.
The secret must be Base32
$editorTotpSecret must contain only the letters A–Z and the digits 2–7. A plain passphrase will not work. Authenticator apps decode whatever you type as Base32 before hashing it, so a passphrase would decode to different bytes on the app's side than on the server's, and every code would be rejected.
Use the enrolment screen to generate a valid secret rather than inventing one. If the configured value is not valid Base32, the editor refuses to run and tells you so, rather than silently rejecting every code you type.
Keeping the secret safe
Unlike your password, this secret cannot be hashed. The server needs the real value in order to recompute codes, so it sits in config/config.php as plain text.
That is fine as long as Apache executes the file, but if PHP ever stops handling it — the module disabled during an upgrade, a stray config.php.bak, a misconfigured vhost — the file would be served as text and the secret handed to whoever asked. This is what config/.htaccess is for. Make sure it is in place and that nothing in your host's configuration overrides it.
Keep a copy of the secret somewhere safe as well, such as a password manager. See If you lose your phone.
Turning it off
Set the value back to an empty string:
$editorTotpSecret = '';
The code field disappears from the login form and the editor goes back to password only. Remove the entry from your authenticator app too, since it will no longer be checked.
If you lose your phone
There are no backup codes and no recovery email. If you cannot produce a code, the way back in is to edit config/config.php over FTP or SSH and blank the value, which switches two-factor off and lets you log in with your password alone.
To avoid needing that: keep a copy of the Base32 secret in a password manager, or use an authenticator app that syncs or exports its entries. Adding the same secret to a second device also works — TOTP has no concept of a single registered device.
The QR code file is optional
editor-qr.php draws the enrolment QR code in pure PHP. It exists so that your secret is never sent to a hosted QR generator, which would rather defeat the point of the exercise.
It is the largest file in the plugin by some margin. If you would rather not carry it, delete it — the enrolment screen falls back to showing the key for manual entry, which every authenticator app supports. Nothing else in the editor depends on it.
Accessing the Editor
Once configured, append ?editor=yourtoken to any page URL:
https://yoursite.com/about?editor=a3f8c2d1e4b7906f2a1d3c5e7b9f0d2e https://yoursite.com/posts/my-post?editor=a3f8c2d1e4b7906f2a1d3c5e7b9f0d2e https://yoursite.com/documentation/introduction?editor=a3f8c2d1e4b7906f2a1d3c5e7b9f0d2e
If the token matches, a password prompt appears at the bottom of the page — with a second field for your authenticator code if two-factor is enabled. Enter your credentials to unlock the editor. The session lasts for the duration set in $editorSessionTimeout (default 30 minutes) and expires automatically on inactivity.
If the token does not match, the page renders normally with no indication that an editor exists.
Using the Editor
Once authenticated, the editor appears below the page content with two tabs:
Edit this page
Shows the current page's metadata fields pre-populated — title, layout, type, date, author, image, excerpt, and keywords — along with a Summernote rich text editor loaded with the page's current content. Make your changes and click Save page. The file is written to disk immediately and the cache for that page is busted automatically.
New page
Provides the same metadata fields as a blank form, plus a content editor. The Page name / path field determines where the file will be created inside pages/:
aboutcreatespages/about.html, accessible at/aboutposts/my-postcreatespages/posts/my-post.html, accessible at/posts/my-postdocumentation/setup/myguidecreates the file in a subfolder, accessible at that path
Subfolders are created automatically if they don't exist. You cannot overwrite an existing page — if the file already exists you will get an error.
After a page is created the editor opens it automatically so you can continue editing straight away.
Session Timeout
$editorSessionTimeout controls how long an authenticated session lasts without activity, in seconds. The default is 1800 (30 minutes). Adjust it to suit your workflow:
$editorSessionTimeout = 3600; // 1 hour $editorSessionTimeout = 900; // 15 minutes
When a session expires you will be prompted to enter your password again — and your code, if two-factor is enabled.
Security Model
The editor uses several layers of protection:
| Layer | What it does |
|---|---|
| URL token | Hides the editor entirely from anyone who doesn't know the token. No login form is shown without it. |
| Password + bcrypt | Verifies identity on login. The plaintext password is never stored anywhere. |
| Two-factor code (optional) | When $editorTotpSecret is set, a six-digit time-based code from your authenticator app is required alongside the password. Defends against a leaked or reused password. |
| Replay protection | A code stays valid for its full 30-second window, so the same digits would otherwise work twice. The last accepted counter is recorded and anything at or below it is rejected. |
| IP-based lockout | After 5 failed login attempts from the same IP address, the login form is disabled for 15 minutes. Persists across cookie resets. Failed codes count the same as failed passwords. |
| Session regeneration | The session ID is rotated immediately on successful login, preventing session fixation attacks. |
| CSRF token | Every save, create, and logout action must include a cryptographically random token tied to the current session. Protects against cross-site request forgery. |
| Session timeout | Sessions expire automatically after the configured period of inactivity. |
| Secure cookie flags | The session cookie is flagged HttpOnly (no JavaScript access) and SameSite Strict. On HTTPS it is also flagged Secure. |
| Path validation | All file write operations validate that the target path resolves inside pages/. Path traversal attempts are rejected. |
| Misconfiguration guards | The editor refuses to run — rather than failing quietly — if its state files cannot be written or if the two-factor secret is not valid Base32. |
It is worth being clear about what the second factor does and does not buy you. It protects against a password that has leaked, been reused, or been guessed. It does not protect against a hijacked session or a compromised server, and the secret itself sits in plain text on disk because verifying a code requires the real value. Worth having, but a narrower gain than two-factor is usually advertised as.
Troubleshooting
Every code is rejected
Almost always one of two things. Either the secret is not what your app has — regenerate it from the enrolment screen and re-add it — or the server clock has drifted. Codes are accepted within 30 seconds either side of the server's time, so a clock more than half a minute out will reject everything no matter how carefully you type. Check the server's time before suspecting anything else.
"Two-factor secret is not valid Base32"
The value in config.php contains characters outside A–Z and 2–7 — most often because a passphrase was typed in instead of a generated key. Use the enrolment screen to produce a valid one, or set the value back to an empty string to switch two-factor off.
"Editor cannot write its state files"
plugins/editor/ is not writable by the user PHP runs as. The editor stores its failed-attempt counts and its last accepted code counter there, and without them the lockout and replay protections would silently stop working — so it refuses to run rather than pretending to be protected. Make the directory writable and check its ownership.
The Two-factor link in the toolbar does nothing useful
The enrolment screen requires you to be logged in. If your session expired between loading the page and clicking the link, log in again and retry.
Disabling the Editor
Set $editorEnabled = false in config.php. When false, the editor plugin is never included, no editor code runs on any page, and the ?editor query parameter is ignored entirely.