Bolt CMS Docs
Sign in

Authentication

Bolt ships two interchangeable auth backends — a zero-setup flat file for simple sites, and SQL-backed accounts for full account management — switchable from the admin dashboard.

Two backends, one toggle

Authentication in Bolt has two backends, and you choose which one is active:

  • Flat file — a hand- or dashboard-managed list of users in config/users.php. Zero setup, no database. This is the default, and it is all a simple marketing or docs site needs.
  • Database — SQL-backed accounts via the BOLT_SQL data layer: one account per person with a username separate from its login methods, multiple emails and phone numbers, self-registration, one-time-code login, password recovery, and device sessions.

The selector lives in config/auth.php under backend, which is one of file, database, or auto. The effective backend is resolved once per request by bolt_auth_backend(). Whichever is active, the signed-in user is stored on the session in the same shape and read through the same guards (bolt_require_login(), bolt_require_admin()), so the rest of the site — the admin dashboard, page gating, every existing check — works identically either way.

Choosing a backend

Settings live in config/auth.php, which returns an array — the same server-side-only pattern as config/database.php (the whole config/ directory is denied to the web by config/.htaccess):

// config/auth.php
return [
    'backend'                 => 'file',   // 'file' | 'database' | 'auto'

    // The keys below take effect only in database mode:
    'allow_self_registration' => true,
    'require_verification'    => false,    // make a new account verify a contact before it can sign in
    'password_min_length'     => 8,
    'otp_length'              => 6,
    'otp_ttl'                 => 900,      // one-time-code lifetime, seconds
    'reset_link_ttl'          => 900,
    'default_country'         => '1',      // E.164 country code for bare local phone numbers
    'debug_log_codes'         => false,    // DEV ONLY: log codes when email/SMS is unconfigured
];

The backend resolves with built-in defaults, so the file is optional — with no config/auth.php at all, Bolt runs in flat-file mode. The backend value can also be overridden by getenv('BOLT_AUTH_BACKEND'). The three modes:

backendEffective behavior
fileFlat-file directory (config/users.php). The default.
databaseSQL accounts when a database is connected; falls back to flat-file (with a logged warning) if the connection is unavailable, so a transient outage can't lock everyone out.
autoSQL accounts only when the database is connected and provisioned (the auth tables and at least one admin account exist); otherwise flat-file. Safe to leave on.

Enabling database authentication

Don't hand-edit the backend to database on a live site — use the admin dashboard's Authentication panel and click Enable database authentication. That action runs the full provisioning sequence in order:

  1. Confirms a database is connected.
  2. Creates the SQL auth tables (idempotent — there is no migration step to run).
  3. Migrates your existing config/users.php admins into SQL accounts, carrying their password hashes verbatim so their logins keep working.
  4. Confirms at least one administrator account exists, then switches config/auth.php to backend => 'database'.

From that point, flat-file login is off and the database is in charge. config/users.php is left untouched on disk: setting the backend back to file — from the panel, or by hand-editing config/auth.php — re-enables it immediately. That makes the file your break-glass recovery path: you can never be locked out by the database.

Signing in

The sign-in screen at /login adapts to the active backend. In flat-file mode it is a username and password. In database mode it also offers a one-time code and links to register or recover, because the login page reads bolt_auth_capabilities() and shows only what is available.

Database-mode accounts can sign in three ways:

  • Password — with the username, or with any verified email or phone number on the account.
  • Email one-time code — a six-digit code sent through BOLT_MAIL.
  • SMS one-time code — a code texted through Twilio, auto-filled by the browser where WebOTP is supported.

Accounts, emails & phones

Each account is one human identity. The username is a stable handle that is kept separate from how the person logs in. Login methods — emails and phone numbers — live in a companion table, so an account can hold several of each (a personal and a work email, two phones, and so on).

A contact must be verified before it can resolve a login or receive codes — receiving the one-time code at it is the proof of control. A verified email or phone can belong to more than one account; that shared contact is what links separate identities together for the account switcher (below). Signed-in users manage their own profile, contact methods, and active devices from the self-service page at /account.

Account switching

Because a verified email or phone can be shared by several accounts, a signed-in user can hold more than one identity behind a single login. The site header carries an account switcher: open the account menu and any other account that shares one of your verified contacts appears under Switch to.

Switching is swap-in-place — one identity is active at a time. Selecting another account signs you into it on this device and signs the previous one out (its device session is revoked), so the session always reflects exactly one identity.

  • Peer accounts switch in one click — a shared verified contact is the proof, so no password is asked.
  • Administrator accounts require a password step-up: the switcher prompts for that account's password before it will switch in, so a shared contact is never a no-password path into an admin account.

Signing in with a one-time code sent to a shared contact lands you in one of its accounts; the switcher then reaches the others. Signing in with a password always lands you in the account whose password you entered.

Only accounts that share a contact verified on both sides are offered, only active accounts appear, and the shared contact is masked in the menu. Switching is rate-limited and recorded in the audit log.

Self-registration

When allow_self_registration is on, visitors create their own accounts at /register with a username, a password, and at least one contact. Whether a brand-new account can sign in right away is your call:

  • require_verification => false — the account is active immediately; a contact still has to be verified before it can be used to log in or receive codes.
  • require_verification => true — the account stays pending and cannot sign in until an email or phone is verified with a one-time code.

Administrators can also create accounts directly from the dashboard, optionally marking their contacts as already verified.

Password recovery

The “forgot password” flow at /recover uses the same one-time-code primitive: the user receives a code by email or SMS, enters it, and chooses a new password. Recovery is also available as an emailed reset link (/reset?token=…) for an email-only, one-click path. Both routes are single-use and rate-limited, and both converge on the same set-password endpoint.

One-time codes & WebOTP

One-time codes are six digits, short-lived (otp_ttl), and single-use. Email codes go out through BOLT_MAIL; SMS codes go through the bundled Twilio sender.

SMS codes support WebOTP auto-fill. The text message ends with an origin-bound line — @your-host #123456 — so a supporting browser can read the code and fill it in. On secure origins with Android Chrome the field fills automatically; everywhere else the code-entry field carries autocomplete="one-time-code", which surfaces the keyboard suggestion on iOS, and manual entry always works. Auto-fill is a progressive enhancement, never a requirement.

SMS / Twilio

SMS delivery uses a small, dependency-free Twilio sender (includes/bolt_sms.php) in the same spirit as BOLT_MAIL: a single raw HTTPS call, and a graceful no-op when unconfigured. Credentials live in config/sms.php:

// config/sms.php
return [
    'account_sid' => '',   // BLANK disables SMS (a graceful no-op)
    'auth_token'  => '',
    'from_number' => '',   // E.164, e.g. +15551234567, or a Messaging Service SID
    'timeout'     => 15,
    'verify_peer' => true,
];

Values resolve config/sms.phpgetenv('TWILIO_ACCOUNT_SID' | 'TWILIO_AUTH_TOKEN' | 'TWILIO_PHONE_NUMBER'), so you can keep the token out of the repo. When any required field is blank, an SMS send is logged and returns a skipped status rather than transmitting — email codes and manual entry still work. The same fields are editable from the dashboard's Authentication panel, where the auth token is shown only as set / not-set.

Sessions & devices

In database mode every sign-in records a device session (browser and IP, first seen and last seen). A user can review and revoke their sessions from /account, and an administrator can do the same from a user's Manage view. Revoking a session signs that device out on its next request — remote sign-out without changing the password.

Admin dashboard

The admin dashboard gains an Authentication panel: the backend selector, a live status line (database connected, provisioned, SMS ready), the account-policy toggles, the Twilio fields, and the Enable database authentication action.

The existing Users panel is backend-aware. In flat-file mode it manages config/users.php exactly as before. In database mode it shows each account's status and contacts and lets you create, edit, disable, or delete accounts; manage a user's emails, phones, and sessions; and send a password reset. Every endpoint behind the dashboard is admin-gated server-side.

API endpoints

Public auth endpoints under /api/auth/ (the database-only ones return 404 in flat-file mode):

EndpointPurpose
POST /api/auth/loginPassword sign-in (dispatches to the active backend).
POST /api/auth/logout · GET /api/auth/meSign out; report sign-in state and capabilities.
POST /api/auth/registerSelf-registration.
POST /api/auth/otp/request · /otp/verifyIssue and verify a one-time code (login, recovery, or contact verification).
POST /api/auth/password/recover · /password/setEmail a reset link; set a new password.
GET /api/auth/accountThe signed-in user's profile and contacts.
POST/PUT/DELETE /api/auth/account/contactsSelf-service contact management.
GET/DELETE /api/auth/account/sessionsList and revoke your own devices.

Admin endpoints under /api/admin/ (all admin-gated):

EndpointPurpose
GET/POST/PUT/DELETE /api/admin/usersBackend-aware user CRUD (flat-file by username, SQL by id).
…/users/<id>/contacts · /sessions · /resetManage a SQL account's contacts and sessions; send a reset.
GET/PUT /api/admin/auth/settingsRead and write the auth + SMS settings.
POST /api/admin/auth/provisionEnable database auth (create tables, migrate admins, switch backend).

Security

  • Passwords are stored with password_hash() (bcrypt) and verified in constant time; an unknown identifier still runs a dummy verification so timing does not reveal which accounts exist.
  • One-time codes are hashed at rest and consumed atomically — a single guarded compare-and-set (BOLT_SQL's update_if) means a code or reset token can be redeemed exactly once, even under concurrency.
  • Rate limiting and enumeration resistance — login, registration, code requests, and verification are throttled per identifier and IP, and the public flows return the same response whether or not an address is registered.
  • Shared verified contacts & switching. A verified email or phone may be a login for more than one account; controlling it — by receiving its one-time code — is the trust root. The account switcher moves between identities that share a contact verified on both, but switching into an administrator account always requires that account's password. Because a recycled or transferred number or address could later be re-verified by someone else, keep stale contacts off your account.
  • Secrets stay out of the repository. config/auth.php and config/sms.php are git-ignored (with .example templates committed), and the Twilio token is never logged or returned to the browser.

How it is stored

Database mode persists everything through BOLT_SQL, as ordinary JSON-defined objects created automatically on first use:

ObjectHolds
accountThe identity: username, password hash, name, role, status.
account_identityLogin methods — each email or phone, with its verified / primary flags.
account_otpOne-time codes (hashed), with purpose, expiry, and attempt count.
account_resetPassword reset-link tokens.
account_sessionDevice sessions for the list-and-revoke feature.
account_attemptThe rate-limit and audit log.

See the SQL data layer for the storage these objects use, sending email for the channel that delivers codes and reset links, and extensions for building features on top of the signed-in user.