First-run boot-token setup — reusable pattern
Extracted from customsso-manager for reuse on other self-hosted PHP projects. Solves the "how does the first admin account get created" problem without shipping a default credential.
License: MIT — do what you want.
The problem
A self-hosted app that ships with admin / admin (or admin / <install-time random> written to a file no one reads) is a Shodan magnet. But the alternative — a public /register endpoint — is a race the wrong person can win. The boot-token pattern threads the needle:
install.shmints a 32-byte hex token and writes it to/etc/<app>/first-run.token, ownedroot:<web-group>, mode0640(or0660if you want the web layer to be able to clear it after use — see below).install.shprints a URL:https://<host>/setup?token=<hex>.- Operator opens that URL, fills in username + email + password + password-confirm.
- Controller verifies the URL's
tokenmatches the file (constant-time compare), creates the user withsuperuserrole, deletes the token file. - Every subsequent request to
/setupreturns 404 because the file is gone.
Result: no default credential ever hits the DB, shell history, or a systemd unit. The setup URL is one-shot, single-use, and self-destroys. The token file is root-owned so only root (or the web layer, if you gave it group read) can see it.
What's in this bundle
| File | Role |
|---|---|
Setup.php |
The controller — show() renders the form, submit() verifies token + creates the user + deletes the file. Also exposes alreadySetUp() for the app's router to trigger a redirect. ~150 lines. |
setup.view.php |
The setup form HTML. Uses $h() for escaping — swap for whatever your project uses. |
reset-setup-token.sh |
Ops helper — mint a fresh token any time. Used when the operator lost the install-time URL, or for lockout recovery when there's no admin left. |
install-snippet.sh |
The chunk of the installer that mints the initial token + prints the URL. Copy the shape into your own install script. |
index-routes.php |
Front-controller routing glue — the redirect-to-/setup gate that fires while users is empty, plus the /setup GET + POST route lines. |
Design notes
Why 32 bytes of hex? 256 bits of entropy. Bruteforcing 2^256 in the 5-minute window between install completion and first browser hit is not a threat any project's setup token should worry about.
Why root-owned, not web-owned? The file predates the first request. install.sh runs as root; there's no user session yet. On success the controller unlink()s the file — that requires the web user to have write on the parent directory (/etc/<app>/), which most installers set up as root:<web-group> 0770 anyway. If you don't want to give the web user directory write, use mode 0660 on the file and have the controller file_put_contents('', LOCK_EX) to truncate it instead of unlink (functionally equivalent — alreadySetUp() treats an empty file as "gone").
Why constant-time compare? Against a timing side-channel. See hash_equals() in the code.
Why print the URL twice — hostname AND primary IP? Because install.sh often runs on a box whose hostname is localhost.localdomain (fresh cloud VMs), and an operator claiming the URL from a laptop needs a URL that isn't https://localhost/…. The snippet auto-detects and prints an IP fallback.
Race window: between the token file existing and the operator claiming it, anyone with local read on the file could hijack. Mitigation: the token file's parent dir is 0770 root:<web-group> — non-web local users can't read it. If you're paranoid, chmod 0700 /etc/<app> and give the web user only ephemeral access via a wrapper.
Lockout recovery: if you lose the token or the operator never claimed it before the file was cleaned up, run reset-setup-token.sh as root — it mints a fresh token and prints a fresh URL. Works both pre-first-admin AND post-first-admin (as long as the users table is empty, which is what alreadySetUp() checks).
What you need to swap for your own project
Look for these classes/constants inside Setup.php:
\CustomSSO\Lib\Db— thin PDO wrapper. Replace with your project's DB access.\CustomSSO\Lib\Router— hasredirect($path). Trivial to reimplement.\CustomSSO\Lib\Session— the setup form usescsrfField()+requireCsrf(). If your app doesn't have CSRF yet, either drop these two calls (the token itself acts as an out-of-band CSRF) or swap in your CSRF helper.\CustomSSO\Models\User::create()— swap for your user create call. Note therole='superuser'assignment.\CustomSSO\Lib\Audit::log()— audit-log helper. Drop the calls entirely if your project doesn't have one, but you're strictly better off keeping them.- The
TOKEN_FILEconstant —/etc/customsso-manager/first-run.token. Change to your project's path.
The view file uses $h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES); — trivial to keep or swap.
Route wiring
// Redirect anyone hitting the app to /setup while users table is empty
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
if (!\App\Controllers\Setup::alreadySetUp()
&& !str_starts_with($path, '/setup')
&& !str_starts_with($path, '/assets/')) {
header('Location: /setup', true, 302);
exit;
}
// Route the setup form + submit
$r->get ('/setup', [\App\Controllers\Setup::class, 'show']);
$r->post('/setup', [\App\Controllers\Setup::class, 'submit']);
Install-time boot-token mint (adapt from install-snippet.sh)
TOKEN_FILE=/etc/YOURAPP/first-run.token
TOKEN=$(openssl rand -hex 32)
echo "$TOKEN" > "$TOKEN_FILE"
chown "root:$WEB_GROUP" "$TOKEN_FILE" # apache / www-data
chmod 660 "$TOKEN_FILE"
BOOT_URL="https://$LISTEN_HOST/setup?token=$TOKEN"
# print URL + fallback URL by primary reachable IP if LISTEN_HOST is loopback-y
# see install-snippet.sh for the auto-detect logic
Security audit checklist (for reviewers of a port)
- [ ] Token is 256 bits of entropy (32 bytes hex from a CSPRNG —
openssl rand -hex 32,random_bytes(32),/dev/urandom) - [ ] Token file is
root-owned, mode0640or0660, in a0770 root:<web-group>parent dir - [ ] Token comparison uses
hash_equals()(constant-time) - [ ]
alreadySetUp()check gates BOTH the setup form AND the app's front controller (redirect-to-setup while unclaimed) - [ ] Post-success, the token file is
unlink()ed OR truncated to empty - [ ] First-user role assignment is
superuser(or whatever your equivalent is) — notadmin, so the first user can't get locked out of role management - [ ] Every failure path audit-logs (bad token, bad email, password too short, user already exists) with source IP + attempted username
- [ ] The
/setuproute disappears (404 or 302-away) oncealreadySetUp()returns true
Reference implementation is production
customsso-manager has shipped this pattern since v0.3.0 (2026-04). No CVEs against it as of v1.0.1 (2026-07-16). Integrated with 2FA (bypass on broken mail requires a marker file — SECURITY.md § "2FA bypass on broken mail requires an operator-created marker file") so an attacker who wins the boot-token race still needs mail or the marker.
Questions / bugs
signing@voip-stuff.net — GPG key 046E8CA0EE6A755B.