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:

  1. install.sh mints a 32-byte hex token and writes it to /etc/<app>/first-run.token, owned root:<web-group>, mode 0640 (or 0660 if you want the web layer to be able to clear it after use — see below).
  2. install.sh prints a URL: https://<host>/setup?token=<hex>.
  3. Operator opens that URL, fills in username + email + password + password-confirm.
  4. Controller verifies the URL's token matches the file (constant-time compare), creates the user with superuser role, deletes the token file.
  5. Every subsequent request to /setup returns 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:

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)

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.