Advanced authentication

Optional TOTP, recovery codes, reset and magic links, OAuth/OIDC, Google and tenant-allowlisted Entra.

One sign-in pipeline, several primary methods: password, email link, Google or Microsoft Entra ID. An enrolled authenticator remains mandatory for all of them. External accounts are linked explicitly from an existing, password-confirmed account; matching email addresses never create or link accounts.

Start here: accounts in a blank project

First create and install a blank project. From its root, run the following once; skip generation if you already added authentication:

Shell

php pow make:auth

The command preserves your existing routes and layout. It generates verified registration, login, password reset, magic links, MFA, account pages and administrator management. It also provides the token services for protected APIs. A demo project already has authentication; do not overlay make:auth on it.

Configure your own SMTP server or local mail catcher in .env. This loopback example uses the same host and port as the start guide; replace the SMTP placeholders with values supplied by your mail service:

.env

APP_URL=http://127.0.0.1:8080

MAIL_DRIVER=smtp

MAIL_HOST=smtp.example.com

MAIL_ENCRYPTION=tls

MAIL_PORT=587

MAIL_USERNAME=your-smtp-user

MAIL_PASSWORD=your-smtp-password

MAIL_FROM_ADDRESS=no-reply@example.com

Keep the project's generated APP_KEY. Do not commit .env. The default MAIL_DRIVER=array delivers no verification mail; a successful registration cannot be tested with that default. For production, use your public HTTPS APP_URL. The mail and link rules below explain why authentication messages bypass the readable mail audit.

Shell

php pow migrate
php pow serve --debug
  1. Open http://127.0.0.1:8080/register and submit your own email, display name and a password of 12โ€“72 bytes.
  2. Open the verification email in your browser and confirm on the page. Opening the email link alone does not create the account.
  3. Sign in at /login. The /account page should show your profile.
  4. Open /account/security, confirm your password, add the setup key to an authenticator app and verify one code. Save the recovery codes privately before leaving the page.
  5. Sign out and back in to check that both the password and a fresh authenticator code are required. A previously used code cannot be replayed.

To appoint your verified account as an administrator, use a second, trusted terminal in the project folder:

Shell

php pow auth:admin you@example.com

Replace the example email with the account you verified. Sign in again, then visit /account/users. An ordinary member cannot open that page or promote themselves. See the generated-module reference for file locations, administrator safeguards and older-project boot hooks. Restart the server after PHP or configuration changes; use debug only locally.

Try the complete demo

For a new application rather than this repository's demo, generate the same security flows plus verified registration and user management with php pow make:auth.

Shell

composer install
php pow migrate
php pow db:seed
php pow serve

Sign in at /login and open /account/security. Confirm your password to enroll an authenticator. Enter the setup key into an authenticator app, or open the otpauth:// link on a device with an app installed. Confirm one code, then save the ten recovery codes: they are displayed once, never saved in plaintext. No JavaScript, remote QR service or third-party enrollment request is needed.

The setup key is a secret, not a password. Do not copy it into chat, screenshots or logs. Enrollment expires after ten minutes; an unfinished sign-in expires after five minutes. TOTP uses RFC 6238, SHA-1, six digits and 30-second steps, accepting one step of clock skew in either direction. A database compare-and-swap prevents a time step from being accepted twice; recovery codes are deleted atomically when used.

Disabling MFA requires both your password and an unused authenticator or recovery code. It revokes remaining recovery codes and other sessions. To replace the authenticator or obtain new recovery codes, disable it and enroll again. Password resets and magic links do not disable MFA. If both the authenticator and all recovery codes are lost, recovery needs the site operator's independently verified process; there is deliberately no email-only MFA bypass.

/password/forgot emails a reset link; /magic emails a sign-in link. Both expire after fifteen minutes. Opening a link stores it in the browser's private session and redirects to a clean URL. Only a CSRF-protected POST consumes it: mail scanners and link previews do not reset passwords or sign users in. Use the same browser for the confirmation step. A magic link replaces only the password; MFA is still required.

Reset passwords must be 12โ€“72 bytes. A reset updates the password, revokes all other email links and existing sessions, and leaves MFA enabled. New links are bound to the current password hash and security version. Their 256-bit random values are hashed in the database; purpose, expiration and single use are checked in the same transaction as the action.

.env

APP_URL=https://app.example.com

MAIL_DRIVER=smtp

MAIL_HOST=smtp.example.com

MAIL_ENCRYPTION=tls

MAIL_PORT=587

MAIL_USERNAME=your-smtp-user

MAIL_PASSWORD=your-smtp-password

MAIL_FROM_ADDRESS=no-reply@example.com

APP_URL is trusted configuration, never the incoming Host header. HTTPS is required outside loopback development. Keep APP_KEY private and stable; it encrypts TOTP secrets, including pending enrollment. Retired encryption keys are accepted through APP_PREVIOUS_KEYS.

Authentication mail deliberately bypasses the mail audit and queue

The normal PersistingMailer stores full mail content in the readable mail_log. Reset and sign-in messages contain usable credentials, so the demo sends them directly through the configured driver, ignoring MAIL_QUEUE, without copying them into that log or admin UI. SMTP failures are logged without message contents, while the browser sees the same generic response. With the default MAIL_DRIVER=array, no email is delivered and a configuration warning is logged. Configure a real SMTP server or a local test mail catcher.

Email requests have the same browser response for missing accounts, delivery errors and throttled requests. That response alone does not provide complete timing anonymity: SMTP latency can still vary. Rate limits are account-wide for email requests, second-factor checks and security changes. Changing browsers or spoofing forwarding headers cannot reset them. As with any account-wide limit, malicious requests can temporarily deny a victim access; add trusted edge-level abuse controls in production.

Google login

  1. Create a Google OAuth web application and configure its consent screen and permitted users.
  2. Register https://app.example.com/auth/google/callback as an exact authorized redirect URI.
  3. Set the client credentials and restart workers.
  4. Sign in locally, open Account security, confirm your password (and an unused MFA code if enabled), and choose Link Google account.
.env

GOOGLE_CLIENT_ID=your-web-client-id

GOOGLE_CLIENT_SECRET=your-client-secret

The login button appears only when the provider is configured. Unknown external identities are refused even if their email matches an existing account. Google identities use verified issuer plus subject, not a mutable email address. Unlinking also requires reauthentication and revokes other application sessions.

Microsoft Entra ID

  1. Create a multi-tenant Entra application registration for organizational directories, and add a Web redirect URI: https://app.example.com/auth/entra/callback.
  2. Create a client secret and copy its value, not its secret ID. Configure the application/client ID and explicitly permitted tenant UUIDs.
  3. Arrange user/admin consent in each permitted tenant, restart workers, then link the account from Account security.
.env

ENTRA_CLIENT_ID=your-application-client-id

ENTRA_CLIENT_SECRET=your-client-secret-value

ENTRA_TENANT_IDS=11111111-1111-1111-1111-111111111111,22222222-2222-2222-2222-222222222222

The authorization endpoint uses organizations, but that is not an allowlist entry. Only explicit lowercase tenant UUIDs are accepted. The signed tid, issuer and signing-key issuer must agree with the allowlist. Personal Microsoft accounts, arbitrary tenants, automatic email-based linking and automatic provisioning are not supported. Local MFA still applies even when the external provider performed its own MFA.

For local development register the exact loopback callback URL, matching APP_URL, including port and whether you used localhost or 127.0.0.1. Real provider credentials and consent are required to test the complete external service; deterministic tests use locally signed JWTs and a fake HTTP transport, not real Google or Microsoft sessions.

Reusable framework APIs

Generated authentication module

Generated modules also include versioned APIs: JWT login with MFA, personal Bearer tokens managed at /account/tokens, shared rate limits and locally hosted Swagger UI at /api/docs. Browser account forms remain script-free.

For the installation and first sign-in sequence, use the setup above. The following sections describe what the generator writes and the guarantees to preserve when editing it.

Visit /register. Registration sends a one-use, hashed verification link that expires after thirty minutes. It stores a pending registration, not a sign-in-capable user. A CSRF-protected confirmation POST creates the account; a mail scanner's GET does not. Re-submit the registration form to request another email. Expired pending rows are reclaimed on new requests. Pending and verified passwords are hashed with PasswordHasher. Registrations, verification mail and reset links never enter the readable mail audit.

The default MAIL_DRIVER=array cannot deliver verification email, so registration shows an explicit configuration error rather than granting an unverified account access. SMTP delivery failures are logged without message contents, and the browser receives the same generic response as duplicate addresses and account-wide throttles. As with other email flows, network timing can still vary. Production deployments should add trusted edge-level abuse limits.

After registering and verifying your own email, appoint the first administrator from a trusted terminal:

Shell

php pow auth:admin you@example.com

Everyone registers as a normal member. No automatic first-user administrator, default credentials or unauthenticated elevation route exists. Administrators can list users in pages of 25, block/unblock and grant/remove administrator access. Each change requires the acting administrator's password and an unused MFA/recovery code if enabled. The actor is rechecked inside a transaction, management changes are serialized with a database lock row, and changes revoke the affected user's sessions and pending challenges. Administrators cannot block or demote themselves, and the last active administrator cannot be removed. Unblocking does not revive revoked sessions.

Members can change their own display name at /account; email is deliberately read-only so an edit cannot bypass verification. Account security lives at /account/security, user management at /account/users. Registration, login, reset and magic-link pages are linked from the generated account layout. MFA and explicitly linked OAuth/OIDC accounts reuse the existing framework services.

Generated pathPurpose
app/auth.phpBoot-time service bindings and authentication route loading.
app/auth-routes.phpPublic authentication routes and guarded account/admin routes.
app/src/AuthKit/Editable user/provider, registration, guards, helpers and single-action controllers.
app/templates/auth-kit/Independent account layout and escaped forms; no JavaScript or inline CSS.
public/assets/css/auth.cssSelf-contained copy of the demo stylesheet; does not replace your existing CSS.
database/migrations/*_create_auth_kit.phpReversible schema for verified users, pending registrations, management lock, throttling and shared security tables.
tests/AuthKitTest.phpGenerated round-trip tests for verification, MFA, profiles, blocking and administrator permissions.

The command checks every output path before writing and never overwrites existing code, including on a second run. There is deliberately no --force; edit generated files normally. It does not change your .env, application layout or existing routes. New blank projects have an optional boot hook for app/auth.php. In an older application, add the following inside its boot callback after registering Connection, Environment, Logger, TemplateEngine and encryption services, before the container freezes:

PHP

<?php
$configureAuth = require $root . '/app/auth.php';
if (!$configureAuth instanceof \Closure) {
    throw new \RuntimeException('app/auth.php must return a boot callback.');
}
$configureAuth($app, $env, $root);

Keep SessionMiddleware before CsrfMiddleware. Generate an APP_KEY with pow key:generate if your existing application has none. Add links to /login, /register and /account in your own site's layout as appropriate. Restart long-lived workers after generation or configuration changes. Neither generation nor administrator promotion boots or migrates the application implicitly.

This generator is intended for blank or not-yet-authenticated applications. It refuses the existing demo provider rather than installing conflicting logins. In a customized existing application, first check for conflicting URLs, UserProvider/Authenticator bindings, auth_* tables and migration ordering. Integrate with existing authentication manually instead of registering two systems. The module uses a separate auth_users table, and does not migrate your existing users. Run composer test and composer stan after installation; the generated tests exercise one boot handling multiple visitors.

Assemble your own application

The framework classes are optional; blank scaffolds do not suddenly gain accounts or authentication routes. A demo scaffold includes the complete application. In an existing application, provide Identity/UserProvider, install the ordinary users and auth_attempts tables, and add a migration returning the reusable security schema:

PHP

<?php
declare(strict_types=1);

return new \PhpOnWheels\Auth\SecurityMigration();

SecurityMigration creates auth_security, auth_recovery_codes, auth_email_tokens and auth_external_accounts, with reversible, portable DDL for SQLite, MySQL, PostgreSQL and SQL Server. User identifiers must fit 191 bytes. External subjects and issuers have the same limit. The existing auth_attempts schema has an auto-increment id, attempt_key VARCHAR(191), attempted_at BIGINT and an index on the last two columns; the demo's migration 0003 supplies it.

PHP

<?php
use PhpOnWheels\Auth\{Authenticator, EmailTokens, Identity, LoginFlow, Mfa, SecurityState};
use PhpOnWheels\Container\RequestScope;
use PhpOnWheels\Session\Session;

$state = new SecurityState($database);
$mfa = new Mfa($database, $state, $encrypter);
$tokens = new EmailTokens($database, $users, $state);

// Immutable services are singletons; Authenticator/LoginFlow are scoped.
$app->container->singleton(SecurityState::class, fn (): SecurityState => $state);
$app->container->singleton(Mfa::class, fn (): Mfa => $mfa);
$app->container->scoped(Authenticator::class, fn (RequestScope $scope): Authenticator =>
    new Authenticator($scope->get(Session::class), $users, $hasher,
        fn (Identity $user): string =>
            hash('sha256', $user->passwordHash() . ':' . $state->version($user->authIdentifier()))));

// In the request handler, after checking the primary factor:
$user = $auth->validateCredentials($email, $password);
if ($user !== null) {
    $flow->begin($user); // Logs in, or establishes a pending MFA challenge.
}
$flow->pending();
$flow->complete($code); // Only works for an existing pending challenge.

Also bind UserProvider, PasswordHasher, Connection and LoginThrottle; register SessionMiddleware before CsrfMiddleware, and guard account-management routes with RequireAuthentication. LoginFlow and OidcClient are autowired per request. A lazy Closure(): Encrypter may be passed to Mfa so password-only users do not require encryption at boot. Never register the session, authenticator, login flow or OIDC client as a singleton.

PHP

<?php
$secret = \PhpOnWheels\Auth\Totp::secret();
$setupUri = \PhpOnWheels\Auth\Totp::uri($secret, $accountName, 'My application');
$recoveryCodes = $mfa->enable($userId, $secret, $code); // Show once.
$mfa->enabled($userId);
$mfa->verify($userId, $code); // Consumes the TOTP step or recovery code.
$mfa->disable($userId, $unusedCode);

$token = $tokens->issue($user, 'password-reset', lifetimeSeconds: 900);
$reset->reset($token, $newPassword); // PasswordReset; transaction + revocation.
$token = $tokens->issue($user, 'magic-login');
$tokens->consume($token, 'magic-login', static function (Identity $identity) use (&$primary): void {
    $primary = $identity;
});
// After the database transaction has committed:
$flow->begin($primary);

Authenticate and authorize enrollment, disabling and external-account changes before calling these low-level APIs. Mfa::verify() is intentionally consuming; do not call it once to preview and again to log in. EmailTokens::consume() wraps its callback in a transaction: use the same connection for the action, avoid nested transactions, and do not perform irreversible external effects inside it.

SecurityState::revoke($userId) invalidates stamped sessions and pending challenges on their next request. Keep UserProvider::updatePasswordHash() on the same transactional database connection when using PasswordReset. Authenticator::login() remains a trusted low-level operation: bypassing LoginFlow in your own controller bypasses your MFA policy.

Schedule EmailTokens::purge() and LoginThrottle::purge() to reclaim expired rows. Expiration is enforced on every read whether or not housekeeping runs; there is no internal scheduler.

Other OAuth 2.0/OpenID Connect providers

OidcProvider accepts a name, client ID/secret, callback URI, trusted HTTPS authorization/token/JWKS endpoints and exact allowed issuers. Register it in OidcProviders and bind OidcHttp to CurlOidcHttp (requires ext-curl), or supply a test transport. Provider responses never select URLs. The client implements the OAuth 2.0 authorization-code flow with PKCE S256, browser-bound one-use state, nonce and five-minute authorization state. ID tokens must have valid RS256 signatures, issuer, subject, audience/authorized party, nonce, issued-at and expiration claims. JWT and JWKS verification uses firebase/php-jwt; no token parser or asymmetric signature scheme is handwritten.

Use OidcClient::start() and finish() to obtain a verified ExternalIdentity. ExternalAccounts::user() resolves only previously linked identities. For linking, pass the current user ID and a fresh security stamp to start(); after finish(), verify that the same signed-in user and stamp remain before calling ExternalAccounts::link(). The demo controllers show the complete pattern. No access or refresh tokens are retained, and provider email claims are not trusted as ownership proof. This is authentication over OAuth/OIDC, not an OAuth authorization server or a generic non-OIDC social-profile client.

HTTPS peer verification is always enabled, redirects are disabled, requests have time and response-size limits, and signing keys are fetched only from configured endpoints. Provider configuration is read at boot, so restart long-lived workers after changing it. Keep application clocks synchronized. Set authentication responses to Cache-Control: no-store; do not log URL query strings, tokens, setup secrets or recovery codes. Configure any upstream proxy/access logger to redact query strings on auth callback and email-link routes too.