Skip to content

Pramnos Authentication & User Management Guide

Overview

The Pramnos Framework provides a comprehensive authentication system that supports multiple authentication methods, user management, permissions, JWT tokens, session management, and OAuth2 capabilities. The system is modular and extensible through addons.

v1.2 New Features: - Database Authentication Driver — Native auth without addons - Two-Factor Authentication (2FA/TOTP) — Time-based one-time passwords - Login Lockout — Brute-force protection with exponential backoff - Session Tracking — Bot detection and session monitoring - OAuth2 Server — Full league/oauth2-server integration - Security Hardening — CSRF, session cookie, view escaping - Auto-Login Lifecycle — Built-in login/logout handling

See related guides: Pramnos_Security_Guide.md, Pramnos_Authorization_Guide.md

Architecture

Core Components

  1. Auth System (Pramnos\Auth\Auth) - Main authentication controller
  2. User Management (Pramnos\User\User) - User data and operations
  3. JWT Support (Pramnos\Auth\JWT) - JSON Web Token implementation
  4. Permissions (Pramnos\Auth\Permissions) - Access control system
  5. Session Management (Pramnos\Http\Session) - Session handling
  6. Token Management (Pramnos\User\Token) - User tokens and API access

Authentication Flow

// Basic authentication flow
$auth = \Pramnos\Auth\Auth::getInstance();
$success = $auth->auth($username, $password, $rememberMe);

if ($success) {
    // User is authenticated, session is set
    $user = \Pramnos\User\User::getCurrentUser();
} else {
    // Authentication failed
    $response = $auth->lastResponse;
    echo $response['message'];
}

User Authentication

Basic Login

// Simple login
$auth = \Pramnos\Auth\Auth::getInstance();
$result = $auth->auth('user@example.com', 'password');

if ($result) {
    echo "Login successful";
    // User session is automatically created
} else {
    echo "Login failed: " . $auth->lastResponse['message'];
}

Remember me

auth($username, $password = '', $remember = true, …) — the third parameter defaults to true, so the basic login above already sets the persistent login cookie. Pass false for a login that lasts only as long as the browser session:

// Session-only login: no persistent cookie
$auth = \Pramnos\Auth\Auth::getInstance();
$result = $auth->auth('user@example.com', 'password', false);

What the browser carries. Every sign-in gets its own token, Pramnos\Auth\RememberTokens, in the form selector.validator. It goes in $_SESSION['auth'], in the auth cookie, and in the login response's auth key, whichever path established the session, an application's own user addon included. It is random and unrelated to the password. The remember_tokens table keeps only the selector and a hash of the validator, so the table alone signs nobody in.

A token ends in each of these cases: - it expires ('auth' => ['remember_days' => 30]); - this browser signs out (logout()); - the person signs out everywhere ($auth->signOutEverywhere($userId)), which also flags each live session to sign out on its next request; - the password changes, from any path, because each token remembers the password hash it was issued under.

Signing in again from the cookies, $auth->rememberLogin(), uses the token once and issues a new one. A copied cookie therefore stops working as soon as the browser it came from returns. authCheck() calls rememberLogin() when no auth addon is registered, so an application needs no addon for remember-me, and init scaffolds none. The framework's UserDatabase addon, where an application still registers it, does the same.

Never print $_SESSION['auth'] into a page, not as a JavaScript variable and not in a header the page reads. It signs its holder in. An API call from the page authenticates with the session cookie and the page's CSRF token (X-CSRF-Token, from <meta name="csrf">), or with an access token.

Code that signs in again by handing auth back where the stored hash was expected keeps working with the token: auth($username, $auth, true, true), as an application's own auth addon's onAuthCheck() does, or a mobile client re-authenticating with the auth it was given. The driver accepts this account's unexpired token there. auth() then spends it, because the sign-in handed back its replacement: a remembered visit leaves no live token behind, and a client reads the new auth. Signing in again in a session that had a sign-in, as an administrator's "log in as" does, ends the token it replaces.

Until migrate has created remember_tokens there is nowhere to keep a token. A sign-in then keeps the credential it had, and the log says to migrate.

An auth cookie from before the tokens, which held the password hash, signs in once the old way and is replaced with a token, so the upgrade signs nobody out. After the cookies have had time to turn over (they last 14 days and are refreshed while used), refuse the old ones:

'auth' => ['legacy_remember' => false],

A CDN rule, a service worker and the page cache's early path cannot open a session, so they cannot tell a signed-in visitor from an anonymous one. A login marker tells them:

'session' => ['login_marker' => 'logged_in'],
  • What it is. Its value is 1 and it holds no secret, so it is not HttpOnly: it has to be readable.
  • When it is set. Every sign-in sets it, after the addon or the built-in path, so an application's own user addon gets it too. A remembered sign-in keeps it as long as the remember-me cookies.
  • When it is cleared. Signing out clears it, and so does the session tracking when it finds the marker with nobody signed in, because the session that set it has ended.
  • Why logged_in. The name begins with logged, which the page cache's default bypassCookies already bypasses on. That pattern is anchored at the start, so a name like pramnos_logged would not match.

It is off until it is named.

Signing in as somebody else, and back

Support often needs to see what a customer sees. Auth::impersonate($userId) (or Impersonation::start()) signs the administrator in as another account, and Auth::endImpersonation() brings them back without a password:

$why = \Pramnos\Auth\Auth::getInstance()->impersonate($customerId);   // null, or why not
// the framework's own: POST Users/loginas/{id} from the administration

// on every page, in the theme: who this is, and the way back (a POST with the token)
echo \Pramnos\Auth\Impersonation::banner(sURL . 'Impersonation');
  • Nothing that signs the administrator in stays in the browser. Their token ends when the other sign-in begins. The way back is a one-time key held only in the impersonated session. Its hash is kept in impersonations, with who, as whom, and how it ended.
  • Coming back (POST /Impersonation) ends the impersonated sign-in's token and signs the administrator in with a fresh one. The way back is then spent.
  • Ending it otherwise. The way back is open for eight hours. Signing out while impersonating closes it, so neither account stays signed in.
  • The activity log records both events: impersonate/impersonated and impersonation_end.
  • A shortcut of the application's own undoes it. Code that signs the administrator back in on an admin request (from a cookie of its own, say) does so a moment after the impersonation starts. Remove it, or skip it while Impersonation::current() is not null.
  • Who may. From the super role up, and only as an account of a lower usertype: never one's own, an equal or a higher one. Configure it with:
'auth' => ['impersonation' => ['min_role' => 'admin', 'hours' => 4]],

Encrypted Password Authentication

// Login with the stored hash instead of the plaintext password
$auth = \Pramnos\Auth\Auth::getInstance();
$storedHash = $user->password; // exactly as stored in users.password
$result = $auth->auth('user@example.com', $storedHash, false, true); // Fourth parameter: password is the stored hash

The driver compares the value with users.password as a plain string. Pass the hash exactly as it is stored; a newly computed password_hash() has its own random salt and never matches.

Logout

// Logout current user
$auth = \Pramnos\Auth\Auth::getInstance();
$auth->logout();
// This triggers the 'Logout' addon events and clears the session

Credential Verification (Without Session)

If you need to verify a user's credentials (username/password) without actually logging them in (e.g. for API tokens, OAuth2 Password Grants, or verifying a password before sensitive actions), use the central static method User::validateUserCredentials():

$credentials = \Pramnos\User\User::validateUserCredentials($username, $password);

if ($credentials !== false) {
    // Credentials match!
    $userId = $credentials['userid'];
    $username = $credentials['username'];
    $email = $credentials['email'];
} else {
    // Invalid credentials
}

This method delegates to Auth::getInstance()->verifyCredentials($username, $password) which resolves the active authentication logic in the proper priority order (respecting any registered addons like UserDatabase or custom AuthDriverInterface drivers).

Two audiences for one refusal: revokeapplication

Account::revokeapplication() answers both an XHR and a plain form post, and the difference is decided once by sendRevokeResponse():

caller answer
X-Requested-With: XMLHttpRequest a JSON body, then the response ends
a browser form a flash message, then a redirect to applicationsUrl(), by default <routeBase>/applications

Worth stating because getting it wrong is invisible from one side. An XHR handed an HTML flash page fails at response.json() and surfaces as «something went wrong» with no clue that the request was simply missing a field. A form post handed a bare JSON body shows the visitor {"success":false,…} as the entire page.

The redirect belongs in sendRevokeResponse(), not at the end of the action. The action has early returns — «client_id is required», «Application not found» — and every one of them answers through sendRevokeResponse(), so a browser form gets its redirect on every path rather than a flash queued for a page that is never rendered. If you override sendRevokeResponse(), it owns ending the response. An area that serves the screen elsewhere (/admin/…) overrides applicationsUrl(), not the whole response. The messages go through t().

The applications a person has authorized: AuthorizedApplications

The listing and the revoking both live in Pramnos\Auth\AuthorizedApplications, so the person's own screen and an administrator acting on their account do the same thing:

$apps = new \Pramnos\Auth\AuthorizedApplications();

$apps->forUser($userId);
// [['appid' => 4, 'name' => 'Assistant', 'apikey' => '…', 'active' => true, 'token_count' => 2,
//   'last_used' => 1760000000, 'consented_at' => '2026-01-02 03:04:05', 'scope' => 'mcp offline_access', …]]

$apps->revoke($userId, $appId, byUserId: $administratorId);   // how many tokens were revoked
  • Listed: a live token, or a consent. A consent whose tokens have all expired is still listed, with token_count 0. It would approve the next authorization without asking, so it must be visible to be withdrawn. last_used is the latest of a token's last use and its creation.
  • A deactivated application is listed and revocable. Its active is false, but its refresh tokens are as live as anybody's. byClientId() finds it by client id whatever its status.
  • Revoking sets every live token the application holds for the person, access and refresh, to status 3, kept for the audit trail with its removedate. It deletes the consent, so the next authorization asks again, and records application_revoked in the person's activity. When byUserId is somebody else, the record carries revoked_by. Status 3 is also what EndSession writes when a client signs a person out.

exportdata is a confirmation on GET and the file on POST

GDPR Article 20, and the split is the security property rather than a UX preference. A GET that produced the file would put every fact the site holds about somebody behind a URL — shareable, in browser history, in a proxy log, and fetchable by any <img src> on any page they visit. So the GET renders what is about to be exported, and only a POST with a valid session token builds it.

The order inside the POST matters too: the data is built before the download headers go out. The other way round hands the browser a file called user_data_export_<date>.json containing an error page.

User Management

What a usertype is, what each one may do, and how to change them

users.usertype is an integer read as a threshold, not an enum. Every framework administration screen opens at 98 — AdminAccess::defaultUsertype(), the super role, moved with roles or the admin_default_usertype setting — and the administration area's own floor is whatever admin.min_usertype says. A comparison, not an equality, is what the framework's own guards are written in.

Below 98, an account is given the screens it needs through a role: set admin_access to mixed and allow the role admin.users, admin.logs… on its page. That is the intended way to give somebody part of the area — see Authorization — rather than a usertype in between, which grants every screen at once.

The account init and user:create --admin create is 99, Root (UserCreate::ADMIN_USERTYPE): on a fresh installation that account is the owner of the deployment, and 99 is the only tier granted * rather than a list that has to be revisited when a capability is added above it.

The framework's own types, and what each may do

Value Type Tone Gains, on top of everything below it
99 Root danger * — every capability, including ones added later
98 Super Administrator danger admin.area and every administration screen, admin.users, admin.settings, admin.permissions…
90 Administrator warning no administration screen unless a role grants it
1 System User (Client Credentials Grant) neutral api.client_credentials — and nothing else, see below
0 Simple User primary account.self

/admin/Users/types renders this table from the registry, so it is always the running answer rather than a copy of it, and an application that declared its own sees its own. The admin.* capabilities are read from the screens themselves — each menu item's floor, which is the floor its controller applies — so UserTypes::can() cannot say a screen opens when it does not. admin.area is the area's min_usertype, devpanel the developer panel's.

Three rules make the numbers behave:

  • A value is a threshold. 95 is an Administrator and has an administrator's label; label(95) says so. Everything between 2 and 89 is a Simple User, because a framework has no basis for inventing roles — those are an application's, and it declares them.
  • Capabilities accumulate downwards. An Administrator has account.self too.
  • 1 is matched exactly and inherits nothing. It is the machine account a Client Credentials grant authenticates as, not a very senior person: giving it account.self would invent a human for a token.
\Pramnos\User\UserTypes::label(95);            // 'Administrator'
\Pramnos\User\UserTypes::can(90, 'admin.settings');   // false — the screen opens at 98
\Pramnos\User\UserTypes::capabilities(98);     // the resolved list
\Pramnos\User\UserTypes::tone(99);             // 'danger' — a meaning, for a theme to colour
\Pramnos\User\UserTypes::options();            // value => label, for Html\Select::addOptions()

Moving the lines: roles

The framework's screens and tools never compare a usertype with a number of their own. They ask Pramnos\User\Roles for a role, so an installation moves every line in one place:

// app/app.php — administrators at 97, one root account
'roles' => ['admin' => 97, 'super' => 98, 'root' => 99],
Role Default What asks for it
manager 80 a dashboard widget's default allowed(); one user's tokens, outside the area
admin 90 phpinfo on the Health screen, the GDPR requests screen's administrator view, auth_trusted_devices_exclude_admins
super 98 every administration screen's floor and the superuser (AdminAccess::defaultUsertype(), superuserUsertype())
root 99 the developer tools: the DevPanel in development and production, its link in the debug bar, Adminer, the debug bar's grants
\Pramnos\User\Roles::level('admin');               // 97 with the config above
\Pramnos\User\Roles::atLeast($user, Roles::ROOT);   // whether this person holds it

A role nobody defined throws rather than answering 0, because a typo must not open a screen to everybody.

A key a tool already has still wins over its role: devpanel.min_usertype, devpanel.adminer_min_usertype, devpanel.production_min_usertype, debug.grant_min_usertype, and the admin_default_usertype and admin_superuser_usertype settings. A screen's own $requiredUserType, set in an application's subclass, wins too. 0 there means "the administration floor", never "anybody".

A test, RolesTest::testNoBundledCodeWritesAUsertypeNumber, fails when bundled code compares a usertype with a literal, so a new screen cannot quietly keep a line of its own.

Declaring your own

// app/app.php
'usertypes' => [
    99 => 'Root',
    98 => 'Super Administrator',
    90 => 'Administrator',
    50 => 'Management User',     // scoped to an organization in this application
    20 => 'Installer',
    1  => 'System User (Client Credentials Grant)',
    0  => 'Simple User',
],
'usertype_tones' => [50 => 'warning'],          // this application treats 50 as privileged
'usertype_capabilities' => [                    // replaces the map — see the note
    99 => ['*'],
    90 => ['admin.area', 'admin.users', 'admin.logs'],
    50 => ['admin.area', 'admin.organizations'],
    0  => ['account.self'],
],

Keyed by the type's floor and read highest-first; declare them in any order, because they are sorted before use — a config listing them lowest-first would otherwise label an administrator "Simple User".

usertype_capabilities replaces the framework's own list rather than merging with it — a capability list is what the application's own can() checks mean, and quietly adding defaults underneath would grant things it did not ask for. The administration screens are added whatever it says: they are not grants but a description of what each screen does.

Every bundled screen — the badge on a user, the label and filter in the list, the select on the edit form, the reference screen — reads this registry. No view carries its own copy, so every screen gives the same answer to "what is 85?" and the same opinion about which number is alarming.

Capabilities are not permissions, and neither is the area's floor

  • A capability answers may this kind of account reach this kind of screen.
  • A permission answers may this account touch this record — per user, in authserver.permissions, editable on the user's own screen. See Authorization, which puts the three side by side and gives the test for telling them apart.
  • The administration area's min_usertype is a third thing: what stops the area being browsable at all, applied before any screen's own check. The scaffolded value is 98, the screens' floor. Under admin_access = mixed or permissions, somebody below it whom a role opens a screen to is let into the area — otherwise the floor would refuse the role before the screen could honour it.

What the administration screen shows about a user

The framework records a user's history in ten stores, all shown on the user's administration screen:

Store What it holds Where it appears
authserver.user_activity_log sign-ins, logouts, whatever the application records with ActivityLog::record($userId, $action, $details), which writes wherever the table exists, whichever features are on Activity panel, and users/activity/{id} for the whole of it
authserver.gdpr_requests export and erasure requests Data requests panel
authserver.loginlockouts failed attempts and any active lockout Login security panel, with Clear lockout
authserver.user_twofactor whether a second factor is on Second factor panel, with Disable
authserver.passkey_credentials registered passkeys same panel, with Revoke per key
authserver.user_privacy_settings what the user chose about their data Privacy choices panel
usertokens issued tokens Recent tokens, and the Tokens screen
tokenactions what was done with them Token actions panel
authserver.user_organizations memberships Organizations panel
mails the mail this address was sent Emails received panel, each subject linking to the mail

The mail panel matches on the address, because mails has no userid. So it answers "was this person actually sent the code" — which was otherwise a question an operator could only take to a mail log indexed by address, with an address copied off this page by hand, and therefore usually answered with "it must have been sent". The limit is the other half of that: mail sent to an address the account used before it was changed does not appear, and nothing pretends otherwise.

The panels are the tailwind theme's. scaffolding/themes/tailwind/views/users/view.html.php draws all ten; the bootstrap and plain-css themes ship a simpler user screen with none of them. The controller collects the records either way, so a project on those themes has the data and has to render what it wants from it.

Every read is guarded on its own. These tables arrive with features — an application without authserver has none of the authserver.* ones — so a panel with nothing behind it renders empty rather than taking the page down, and an empty panel is still rendered: "no GDPR requests" is an answer, and a section silently omitted is indistinguishable from one that never existed.

Three operator actions come with them, and each is recorded in the activity log because each is exactly what an audit needs to show:

users/unlocklogin/{id}        clear a login lockout — the answer to most "I cannot sign in"
users/disabletwofactor/{id}   turn off 2FA for somebody who lost their phone
users/revokepasskey/{id}?credential={n}   remove a credential bound to a device that is gone

disabletwofactor goes through TwoFactorAuthService::disableForOperator(), the named unchecked path: the user's own disable() requires their password, and an operator cannot be asked for somebody else's. revokepasskey matches on the user and the credential, so a request naming another account's key deletes nothing.

The audit trail: what operators did

The activity log is a person's own history. authserver.audit_log is the operators' record of actions, for an administrator or an auditor: who did what, to which account or record.

use Pramnos\Auth\AuditLog;

AuditLog::record('role.granted', $adminId, $userId, ['role' => 'editor']);        // about an account
AuditLog::record('admin.page', $adminId, $userId, [], 'page', 'users/edit');       // …through a screen
AuditLog::change('client.updated', $adminId, 'application', $appId, $old, $new);   // a record changed
AuditLog::change('user.updated', $adminId, 'user', $userId, $old, $new, [], $userId); // …of an account
AuditLog::record('report.sent', null);                                            // by the system

$rows = AuditLog::query(['event' => 'role.*', 'actor' => $adminId, 'since' => strtotime('-7 days')], 50);
$rest = AuditLog::query(['not_event' => ['admin.page*']], 50);                    // all but page views
$next = AuditLog::query(['event' => 'role.*', 'before' => end($rows)['auditid']], 50);
  • Secrets are redacted in the context, the old values and the new: a key whose words name one (password, client_secret, accessToken, api_key, otp, pin, cookie, Authorization…) keeps its place and loses its value, so the trail says a password changed without holding it. Only whole words count, so spin and keyword are kept. A number, a boolean or null under such a key is kept too, so a count of tokens is still a count. AuditLog::redact($array) is the same rule for code of your own, and ActivityLog::record() applies it to its details.
  • The reader takes event (exact, or a prefix: role.*), not_event (a list of the same, left out), actor, target (a userid), object_type, object_id, since/until (Unix times) and before (an auditid, for the next page). It answers newest first, at most 500 at a time, with the JSON columns decoded.
  • The moment is written and compared as a Unix time the database converts (Instant), so it is right whatever zone the session is in. Each row read carries at, the moment as a Unix time; event_timestamp is as the database returns it, in the session's zone on MySQL and with an offset on PostgreSQL. Format at for display.
  • It never breaks the action it audits. With no table, or a database that fails, nothing is written, the reader answers [] and the reason goes to the auth log. It writes wherever the table exists, on MySQL and PostgreSQL alike. On TimescaleDB the table is a hypertable, compressed after 90 days.
  • Nothing is deleted unless asked. How long an audit trail is kept is a decision of law or policy. audit:prune runs daily on the framework schedule and deletes nothing until a retention is set: 'audit' => ['retention_days' => 730] in app/app.php, or audit:prune --days=N by hand. A retention per event is a map of events or prefixes, with * for every event no other entry names: ['admin.page' => 180, 'role.*' => 3650, '*' => 730]. Without *, an event no entry names is kept.

Editing a user: what the screen can change

The edit form covers every column the framework's own schema gives a user — username, email, names, phone, mobile, language, timezone, usertype, active, validated, password — plus the two things that are about an account rather than on it:

Per-user settings. usersettings is a key/value store per user, and the value is JSON:

$user->setSetting('notifications.email', true, $operatorId);
$user->getSetting('notifications.email', false);   // decoded — true, not "true"
$user->listSettings();                              // what the admin screen lists
$user->deleteSetting('notifications.email');        // the default applies again

There were two places to keep something about a user and neither fits an operator-visible switch: users columns are the schema every application shares, and $otherinfo is a blob with no list, no per-key delete and nothing an administrator can read. Deleting is not the same as setting null: no row means the application's default applies, a null value means somebody deliberately set it to nothing.

Two things to know about how the four behave when the store is unreachable — a project that has not run the migration, or a database that is down. The readers degrade and the writers report failure. getSetting() answers the default and listSettings() answers [], because a project with no settings table has no settings, and a framework upgrade should not take down every page that consults a preference. setSetting() and deleteSetting() return false instead, and both log: a caller told a write succeeded will tell somebody the switch was changed, and «removed» about a row that is still there is the one answer an operator acts on and is wrong about. So check the return value on a write and ignore it on a read.

Values keep their type through the round trip — a list stays a list and false stays false, which matters because a store that flattened to text would hand back "0" for a switch somebody turned off, and "0" is truthy. A row whose value is not valid JSON comes back as the raw string, because rows do get written by hand in a database client and refusing to read one is the store deciding an operator's edit did not happen.

Per-user permissions. The grants written directly to this account, with revoke, and a form to add one (object_type, object_id, action, allow/deny). Only the direct grants are listed: the resolver also answers from usertype and group membership, and a screen mixing them would offer a revoke button for a permission that has no row.

Everything is three separate forms on one screen, because they are three separate decisions — saving a name should not resubmit a permission, and a rejected permission should not lose a typed name.

Second factors: which ones exist, and who decides

Four, and they are not equally strong. The order is the policy, and it is the order a step-up offers them in:

Method What it is Strength
passkey WebAuthn — a device holding a key strongest; also a primary method
totp an authenticator app strong; needs enrolling in advance
email a six-digit code, mailed weakest; needs nothing set up in advance
push "Is it you trying to sign in?" on a trusted phone offered first when the account has one — see below

Wrong codes are limited. Each wrong second-factor code (TOTP, a backup code, a mailed code, any registered factor) counts against the pending login, and against the account's lockout like a wrong password. After 'auth' => ['second_factor_attempts' => 5] of them, five by default, the pending login is ended and the next try starts again from the password. If the failures set off the lockout, the result is LOCKED, with its remaining seconds. Without the limit, a password was enough to guess a six-digit code at leisure. A factor whose gateway is down refuses without counting: that is an outage, not a guess.

The second factor everywhere, not only on the login form

The login form asks for the second factor. A site's own sign-in form, a social login and a remember-me cookie do not. Where one PHP session serves a site, an administration and an API, a password alone then reaches all three through whichever door does not ask. Turn on:

'auth' => ['security' => ['second_factor_everywhere' => true]],

Every session is then checked once, in Session::staticIsLogged(), which every "is somebody signed in" path goes through: pages, controllers' guards, /oauth/authorize, and the API's session path.

  • A session the login flow finished is marked (SecondFactorGate::SESSION) and passes from then on.
  • A session signed in any other way, for an account with a second factor, is not counted as signed in. It becomes a pending login, and the login page shows the second step. The wrong-code limit applies to it as to any other, and its wrong codes count against the account's lockout under its username, as the form's would. An account already locked out gets no pending login: it waits until the lockout lifts, and the login page says it is locked and for how long (SecondFactorGate::lockedFor()).
  • A stopped session stays stopped. It reads as signed out, but it is not a guest: while its login waits for the step, or its account is locked out, every page it opens is sent to the step again, at most fifteen minutes. Finishing the login, abandoning it or letting it expire ends that. Asked before the application exists, as a front controller deciding on its page cache does, the session counts as owed and nothing is forgotten. The question asked once the application is up decides. An application that throws away a guest's session on every request loses the pending login with it, and the person signs in again.
  • An account without a second factor, or a browser it trusts, is marked and passes. A remember-me cookie in a browser trusted with "Don't ask again on this device" is therefore not asked again, as the form would not ask.
  • An administrator signing in as somebody else settled their own factor, and the account they become is not asked for its own.

Where the person is sent. A page opened in a browser (a GET asking for HTML, not an XHR or a JSON call) is remembered, and the account screens return to it once the second step is done, where the request names no return of its own. To send the browser straight to the step, name it:

'auth' => ['security' => [
    'second_factor_everywhere' => true,
    'second_factor_url'        => 'login',   // or an area's own sign-in page, or a full URL
]],

Without it, the page simply sees nobody signed in and sends the person to sign in as it would anyone. A form post, an API call or a script is never redirected: it is refused, as signed out.

The application's own login flow. The gate runs the same LoginFlow as the account screens: LoginFlow::make(), which is the class 'auth' => ['login_flow' => App\Auth\LoginFlow::class] names, or the framework's own. A subclass with its own trusted devices, lockout or factors therefore decides what a session owes as well as what the form asks.

Off by default. With it on, a remember-me cookie no longer skips the second factor: "Don't ask again on this device" is what skips it. Nothing of this is inside Auth::auth(), which stays the password check it is.

Trusted devices and sign-in approval

Google's two sign-in conveniences, for every application on the framework. Both are off until an administrator turns them on in System Settings → Security, because each changes what a password can do.

"Don't ask again on this device" (auth_trusted_devices). The two-step page shows the box, ticked by default. Finishing a real second factor with it ticked trusts the browser for auth_trusted_device_days (30, 1–365): a password sign-in from it skips the second step until then. Use does not extend it. auth_trusted_devices_exclude_admins (off) makes usertype ≥ 90 always take the second step — including browsers trusted before it was switched on, which also stop receiving and answering phone prompts.

The server trusts only a box it was sent. pf-auth.js copies the box into whichever form is submitted, as trust_device=1 or 0 (and onto the passkey's address as ?trust=); the step-up remembers it with LoginFlow::chooseTrust(). A page that never sends it trusts nothing — no JavaScript, or an application's own copy of login/login_2fa from before the box. Shown ticked is not chosen: an application that copied the two-step view must add the box (and load pf-auth.js) before its users can trust a device at all.

A new password forgets every trusted device. Account::updatePassword() — where both the change and the reset arrive — calls revokeAll(): people change a password because somebody else may have it, and that person's browser must not keep skipping the second step.

  • Pramnos\Auth\TrustedDevices — trust(), isTrusted(), current(), forUser(), revoke(), revokeAll(). The cookie (pf_trusted_device, HTTP-only, Lax) holds a random token; authserver.trusted_devices holds only Token::lookup() of it.
  • /account/security lists them, marks "This device", and forgets one or all (account/revokedevice, POST with the session token).
  • A password alone, a trusted browser, or an emailed sign-in link never trusts anything.

Approval from a trusted phone (auth_push_approval). Needs push working — VAPID keys and minishlink/web-push (see the Push guide); the settings screen says when it is not. An account with a trusted device that is subscribed to notifications gets the push factor, offered first. The step-up sends it as it begins — nothing to start, as with Google — and the page waits:

  1. The phone's service worker reports receipt (POST /push/ack?token=…) and the page says "Delivered to your phone ✓". After 20 seconds without it the page opens "Try another way".
  2. A number, unless the attempt is plainly the account's own. The waiting page shows it; the page the notification opens (account/approve) offers three and the phone must pick the right one, and the notification's only button is No, it's not me. The exception is a plain Yes / No — on the notification and on the page — for a browser the account trusted before (TrustedDevices::known(): its cookie outlives the trust, KNOWN_DAYS = 365) signing in with nothing unusual (SignInRisk). An attempt that merely looks familiar earns nothing: User-Agent and country are what whoever holds the password chooses, and a lock-screen Yes is what prompt bombing counts on. auth_push_number_matching: risk (default) as above, always for the number every time.
  3. Approved, the waiting page submits by itself (method=push, through requestSubmit() so the box goes with it) and signs in.

The rules: only trusted devices are asked, never every subscribed browser; answering needs a browser signed in as the account and carrying its trust cookie, so a forwarded link approves nothing; the wrong number is a refusal, and a Yes without the number an ask needs changes nothing; the first answer stands (a No and a Yes arriving together are settled by the row, so a refusal is never overwritten); only the waiting browser's session can use an approval, once, within 60 s of the answer (PushApprovals::CONSUME_WINDOW); each ask can be answered for 120 s; at most 3 asks in 10 minutes per account. "No, it's not me" records signin_denied, and mails SecurityChangeNotifier::SIGNIN_DENIED — the password is known to somebody else.

Does it satisfy enrolment? The site decides. push scores 70 so that it is offered first. Whether it also counts towards require_factor_enrolment_from_usertype is auth_push_counts_for_enrolment (PushApprovalSecondFactor::countsForEnrolment()), because a browser can be trusted after a mailed code:

Value Counts when
strong (default) a phone that would be asked was trusted with a factor that counts itself — an authenticator, a passkey, an adaptor of strength ≥ 40 (authserver.trusted_devices.trusted_via, FactorEnrolment::methodIsStrong())
always there is any phone to ask, as Google counts its prompt — for a site that does not need the stricter bar
never never: the account needs its own authenticator, passkey or adaptor

A browser trusted through the phone prompt itself, or before trusted_via existed, counts as not strong.

Never the only way. The page always has "Try another way", and an account whose only other option would be nothing is also offered a mailed code: switching the prompt on lets email through the factor list whatever twofactor_methods says.

Endpoint Who What
GET account/pushstatus the waiting page {state: sent\|delivered\|approved\|denied\|expired\|none, number, devices, expires_in}
GET/POST account/approve?token= the phone, signed in and trusted the question, and the answer
POST push/ack?token= the phone's worker the receipt; the token is the credential
POST push/respond?token=&decision= the notification's buttons No always; Yes only on an ask that needs no number
POST push/subscribe the page now also links the subscription to its trusted device

A passkey inside the username autofill

The passkey button is not where most people sign in with one. Conditional mediation puts the credential in the username field's own autofill list, so signing in is one tap on a suggestion instead of noticing a second button and choosing it. The scaffolded sign-in screen does this out of the box; two things make it work.

The field declares it. The webauthn token is how the specification marks the field a passkey suggestion belongs in:

<input type="text" name="username" id="username"
       autocomplete="username webauthn"
       autocapitalize="none" autocorrect="off" spellcheck="false" required>

The ceremony is started on load and left waiting. pf-auth.js does it when the page has both that field and a [data-pf-passkey-login] button to read the URLs from. The promise settles when somebody picks a passkey — which may be never, and that is the normal case.

window.PramnosWebAuthn.conditional(optionsUrl, verifyUrl)
    .then(function (result) { if (result) { window.location = result.redirect; } });

A browser allows one outstanding credentials.get()

A conditional request holds it for the life of the page. So switching this on naively breaks the passkey button: the second ceremony is refused, silently, and nothing on the page says why.

PramnosWebAuthn.authenticate() cancels any pending conditional request before starting its own, which is what keeps both paths working. If you write your own button, call PramnosWebAuthn.cancelConditional() first.

Three cases stand down rather than interfere, and each matters for a reason:

  • isConditionalMediationAvailable() is absent — older browsers. Calling it there is a TypeError on the sign-in page, so it is feature-detected.
  • it answers false — no ceremony is started at all. Starting one that then failed would hold the single outstanding get() and break the button for exactly the browsers that need it most.
  • the person cancels, or uses the password form — an AbortError, which is the normal ending of a conditional ceremony and is not reported. An error message about a ceremony nobody asked for, appearing while somebody types a password, is worse than no message.

The client is tested by being run: ConditionalMediationClientTest loads the shipped pf-webauthn.js under Node against a stubbed navigator.credentials and asserts, among the rest, that pressing the button cancels the waiting ceremony and still signs the person in.

Email is offered precisely because it needs nothing set up in advance. An authenticator app protects an account only if the person installed one before the day they needed it, and most have not. Mail is a channel every account already has, so it is the only second factor that can be turned on for everybody — and a weak second factor is a large improvement on a password alone.

It is never ranked above TOTP. An account with both is asked for the app and offered mail as the fallback; reversing that would quietly downgrade every account that had done the stronger thing.

Adding a factor of your own — SMS, a push, anything

The three the framework ships — totp, email and push — are adaptors like any other, held in Pramnos\Auth\SecondFactorRegistry (passkeys sit outside the registry). An application adds its own from a service provider's boot():

\Pramnos\Auth\SecondFactorRegistry::register(new \MyApp\Auth\SmsSecondFactor());

Pramnos\Auth\SecondFactorInterface is seven methods:

public function name(): string;                 // 'sms' — travels in forms and the audit log
public function label(): string;                // 'Text message' — what a person is shown
public function strength(): int;                // ordering: app 60, SMS ~40, mailed code 20
public function isEnrolledFor(int $userId): bool;
public function needsSending(): bool;           // true for SMS, false for an authenticator app
public function send(int $userId): bool;
public function verify(int $userId, string $code): bool;

Nothing else changes. The login offers it, the step-up screen renders it, the audit log records which factor carried the sign-in, and auth_newsignin_action can demand it — without the framework knowing your gateway exists.

Three obligations, because the flow cannot check them for you.

  • isEnrolledFor() must be a promise. True means verify() can succeed. An SMS adaptor with no phone number on file must answer false, or the login offers a step-up nobody can complete — a lockout wearing the clothes of a security feature.
  • isEnrolledFor() must not send. It is called while deciding what to offer, including on pages that are never submitted. Sending there texts somebody on every failed password attempt, at your expense.
  • What you send must expire, be single-use and be attempt-capped. Only the adaptor knows what it issued, so the flow cannot enforce it. EmailSecondFactor is the worked example: ten minutes, five attempts and then the code is destroyed, single use.

Registering is not enabling: auth.twofactor_methods still decides. A shared codebase can register an SMS adaptor everywhere and a deployment that does not pay for the gateway leaves it out of the list — no code change, and accounts stay enrolled for the day it comes back.

Registering under an existing name replaces it, which is how an application routes the mailed code through its own transactional provider: register your own email and it is used instead.

Turning the email factor on

Two switches, answering different questions. The application decides the method exists:

// app/app.php
'auth' => [
    'twofactor_methods' => ['totp', 'email'],
],

The default is ['totp'], so an installation that does not ask for this gets exactly what it had. totp is always in the list whether or not you write it — an application cannot switch off the method its existing accounts are enrolled in by adding a config key, which is what omitting it would otherwise mean.

Declaring it is not enough on its own: the installation has to be able to send mail. EmailSecondFactor::isAvailable() is false while smtp_host is empty (Email::isConfigured()). A code nobody can receive is not a factor, so with no mail the method is not offered, and an account that enabled it is treated as not having it rather than asked for digits that never arrive.

The account decides it wants it, from its own security screen, behind its own password (user_twofactor.email_enabled). Attaching a second factor is a change to how an account authenticates, so a borrowed session must not be able to make it — in either direction. An operator cannot turn it on for somebody: that would be adding a credential to another person's mailbox. The administration screen shows the state and nothing more.

Because the two switches are independent, an application can withdraw the method without touching a single account row, and turning it back on restores each account's own answer.

What makes a six-digit code safe enough

Not the hashing. A million possibilities is nothing to a KDF, so the code is stored as an HMAC keyed by the installation secret and the user id — enough that a leaked table hands out no live codes and a row copied between accounts is worthless, and no more than that. What makes it safe is three limits, all enforced inside Pramnos\Auth\EmailSecondFactor:

  • ten minutes, after which it is refused;
  • five attempts, after which the code is destroyed rather than merely refused — a code left alive after the cap can be guessed at while its owner is still holding it;
  • single use — a correct verification deletes the row, so an intercepted code is spent.

Asking again replaces the code rather than adding one, so "send it again" never leaves two live codes.

Sending, and when not to

A code is not mailed when the password is accepted. It is mailed when the screen asks:

$flow->sendEmailCode();          // false when nothing is pending, or no address
$flow->hasLiveEmailCode();       // so the screen can ask without sending
$flow->completeEmailCode($code); // tagged `email`, not `twofactor`

Sending on password success would mail an account that has an app and email as a fallback on every sign-in it never reads — and would mail one for each of somebody else's failed password attempts, which is a way to use your login form as somebody else's mail flood.

The completion is recorded as its own method so an audit can tell which factor actually carried a login. They are not equally strong, and a log that calls them the same thing cannot answer that afterwards.

Passkeys from a SPA

A single-page application authenticates with a bearer token, not a session cookie, and its code is modules rather than <script> tags. Neither difference touches the ceremony — a passkey sign-in is the same conversation with the authenticator either way — so a SPA runs the framework's own WebAuthn client, delivered as an ES module.

What a scaffolded SPA gets, without asking:

Piece Where What it is
Pramnos\Auth\Controllers\ApiPasskey Api\Controllers\Passkey in the project The web Passkey controller, answering a verified assertion with a bearer token
/passkey/* routes app/api-routes.php The seven endpoints, documented in the generated OpenAPI
lib/webauthn.js the SPA source tree The framework's ceremony, with ES exports — framework-owned, not to be edited
loginWithPasskey() and friends lib/api.js The calls a screen makes

A sign-in screen needs this much:

import { passkeySupported, loginWithPasskey } from './lib/api.js';

// The button exists only where the browser can honour it: false on plain http://
// (localhost excepted) and in older browsers.
if (passkeySupported()) {
    const user = await loginWithPasskey();   // no argument: the authenticator decides who
}

loginWithPasskey() stores the issued token exactly as login() does, so everything after it is a normal authenticated call. Passing a username pins the ceremony to one account; passing nothing makes it usernameless, which is the version worth offering — a sign-in that asks for nothing at all.

Enrolment and management are the same shape: registerPasskey(label), listPasskeys(), renamePasskey(id, name), revokePasskey(id). For the autofill experience described above, loginWithPasskeyAutofill() and cancelPasskeyAutofill() are the module's conditional pair.

The cookie still travels, and it is not a credential. A ceremony is two calls, and the challenge issued by the first is held server-side so that only the session that started one can finish it. That correlation is what the cookie carries; credentials: 'same-origin' in the generated client is why it works. Authentication is still the token — /passkey/login seals the request's identity and establishes no login session.

One implementation, and the reason it matters. lib/webauthn.js is generated from the same source the server-rendered login page is served (PasskeyAsset::spaModule()), and the only thing the SPA replaces is the transport: one injected function that carries the API key and the bearer token instead of posting same-origin. Writing a second WebAuthn client is the obvious alternative and the wrong one — the ceremony is exact in a way that does not survive being typed twice, and its failure mode is a flat authentication_failed that names no field.

To add the module to a SPA project scaffolded before it existed:

./<cli> project:resync --debug-panel --all   # writes lib/debug.js and lib/webauthn.js

lib/api.js is the project's own file and is never overwritten, so its passkey functions are a copy-in from the current stub.

What the scaffolded SPA sign-in screen does

Both stacks — Svelte and build-less — ship the same three paths, because a screen that implements only the first one is a dead end rather than a missing feature:

  1. Password. login(username, password).
  2. The second factor, when the account has one. login() throws TwoFactorRequired rather than returning — the password was correct and the account needs more — and the screen replaces the password form with a code form, posting only the code through loginTwoFactor(). The pending login is held server-side, so nothing is carried across; a wrong code leaves it pending and the person tries again instead of starting over.
  3. A passkey, where the browser can honour one (passkeySupported()).

A screen that does not catch TwoFactorRequired shows "Could not sign in" to somebody whose password was right, with no way forward from that screen at all — which is why GeneratedSpaPathsTest asserts both stacks handle it, rather than leaving it to a reviewer.

The code field carries autocomplete="one-time-code", which is what lets a phone offer the code from the message it has just received, and inputmode="numeric", which keeps the numeric keypad without rejecting an alphanumeric backup code.

What the form says when it fails, and while it works

Two things the scaffolded sign-in screens do that are invisible on the machine of the person who built them.

A failed submission is announced. The error box carries role="alert", and — the part that actually does the work — the first field of the form points at it:

$errorFieldAttributes = $errorText !== ''
    ? ' aria-invalid="true" aria-describedby="form-error"'
    : '';
<div role="alert" id="form-error" class="alert alert-error">Wrong username or password.</div>
…
<input type="text" name="username" id="username" autocomplete="username webauthn"
       aria-invalid="true" aria-describedby="form-error" autofocus>

role="alert" on its own is not enough here, and this is the easy thing to get wrong while believing it is done: a live region is announced when it changes, and a server-rendered error has been in the document since before the page existed. It never changed, so most screen readers say nothing. The description is what works with no JavaScript at all — the message is read out as part of the field the moment focus lands on it, and focus lands there on load because the first field carries autofocus.

The first field only. These errors are form-level — «wrong username or password» is about the pair — and marking four fields invalid to report one failure tells a screen reader four things that are not true.

The same split applies to every other message box on a scaffolded page: alert-error and alert-danger get role="alert", which interrupts; alert-info, alert-success and alert-warning get role="status", which waits for the next pause. A success message that interrupts whatever was being read is how a well-meaning accessibility sweep makes a page worse.

A submitted form says it heard the button. Mark the form and pf-auth.js does the rest:

<form method="POST" action="…/login" data-pf-progress>

On submit the submit buttons are disabled, gain a pf-busy class, get … appended to whatever they said (or data-pf-busy-label if the screen wants real words), and the form gets aria-busy="true". The pause is real and unavoidable — the human-check proof runs in a worker and the form waits for it — and a person who cannot tell whether the press registered presses again. A second sign-in attempt is not free: it is a failed attempt against a lockout counter.

One subtlety worth knowing before you touch it. A submit listener that called preventDefault() did one of two opposite things: refused the submit, in which case disabling its button hands somebody a form that can never be submitted; or held it, in which case that hold is exactly when the second press happens. Nothing in the event distinguishes them, so the indicator skips both — it runs a tick after the event and returns if the default was prevented — and whoever is holding marks the form itself:

window.PramnosAuth.markSubmitBusy(form);

which is what pf-humancheck.js does while it waits for its proof.

Telling an account it was used from somewhere new

NewSignInAlert compares the current sign-in's device fingerprint against user_activity_log and emails the account when the combination is new. It reads history that already exists, which is why switching it on does not notify everybody at once.

The site's policy sits around the user's own preference — auth_newsignin_policy on the settings screen:

Value Meaning
optin (default) the account's own preference decides; silence means no
optout the same, except silence means yes — on unless the account turned it off
always every account is notified, whatever it chose
off nobody is, whatever they chose

always exists because for a service where the account is the product, telling somebody their credentials were used from a new device is closer to an obligation than a setting. off exists for the incident where the alert stops being a security feature and becomes the outage's own mailing list. optin is the framework's default so that an upgrade starts nobody's mail.

A new project starts at optout: pramnos init writes it into app.php, where it acts as the default the setting overrides.

'auth' => ['newsignin_policy' => 'optout'],

optin stays the framework's default because existing installations rely on it — an upgrade that begins mailing a user base would be a surprise delivered by a patch release. A newly scaffolded project has no such user base.

optout is the one to reach for on a real user base. Under optin, the people who most need this mail are the ones who will never find the checkbox, so the feature ends up protecting the users who were already careful. optout keeps the choice with the account and changes only where it starts from. It works because the preference stores '0' rather than deleting its row: "chose no" and "never chose" are different states, and this policy is the difference between them.

Nothing about it needs a consent record. The mail goes to the address the account signed up with, about that account's own sign-in, and it can be turned off in one click — a security notification, not marketing. If your installation sends it under optout, the unsubscribe link and List-Unsubscribe headers described in the Email Guide are what keep it that way in a mailbox provider's eyes.

The per-account state is on the user's admin screen, with a toggle when the policy leaves the decision to the user — and a sentence instead of a switch when it does not, because a control that decides nothing is worse than none.

…and making it do something about it

Notifying is the weakest useful response: by the time the mail arrives, whoever had the password is already inside. auth_newsignin_action, beside the policy on the same screen, is what such a sign-in must satisfy before it continues:

Value What a sign-in from an unrecognised device must do
notify (default) nothing — the alert is sent and the login proceeds
authlink wait for a single-use link mailed to the account
require_2fa pass a second factor, even if the account would not normally be asked
require_passkey pass a passkey — the one factor that cannot be phished or read out of a mailbox

None of them can lock a user base out, and that is the design constraint rather than a side note. A demand the account cannot meet — "use a passkey" to somebody who has none — would turn this setting into an outage with a checkbox, so each strict reading falls back to the strongest factor the account actually has, and to a mailed code last, because a mailbox is the one thing every account has. require_2fa therefore imposes a mailed code on an account with no factor at all, regardless of that account's own email-factor switch: the demand is the site's, not the account's, and an account with nothing set up is exactly the one a stolen password threatens most.

A mailbox is only a factor when the installation can send mail. With smtp_host empty, authlink resolves as require_2fa does, and the mailed-code fallback becomes the passkey when the account has one and nothing when it has neither. That is weaker than a code, and it is the only answer that is not a wall in front of every account on the installation.

A device the account has used before is never questioned by any of them, so this costs a step on unrecognised browsers and nothing on the rest.

When the action applies is a separate setting, auth_newsignin_trigger (NewSignInAlert::TRIGGER_SETTING):

Value The action applies to
new_device (default) any browser this account has not been used from
suspicious only a sign-in with a harder-to-explain signal (SignInRisk): a country the account has never used, two places at once, a different country too soon to have travelled, or a success straight after a run of failed attempts

"A device this account has not used" fires constantly on a real user base — new phones, cleared cookies, a borrowed laptop — so a demand attached to it is a step everybody pays regularly. suspicious spends the friction where there is something to be suspicious about. new_device is the default because it is the conservative reading: it asks more often, not less.

The link is a link in an authentication email, which the alert deliberately refuses to be. The difference is that this one is expected: the person submitted a password seconds ago and is looking at a page telling them it is coming. It is useless to somebody who has the password but not the mailbox — which is the entire point — it expires in fifteen minutes, and it works once. LoginFlow::sendAuthLink() refuses when no login is pending, so the endpoint cannot be used to mail an arbitrary account a way in.

It completes in a browser that never saw the password leg, because people read mail on a phone: the token is the authorisation, and it is spent before a session is established.

// What the flow exposes
$flow->sendAuthLink($returnUrl);   // false with nothing pending
$flow->completeAuthLink($token);   // tagged `authlink`

The four rules, and where they live

All four are in Auth\NewDeviceAuthLink, not in the callers — what makes the method safe is not the token but these:

Rule Constant Why it is the whole security when the others are absent
Single use — The mail stays in the inbox afterwards, and a provider's link-preview fetch counts as an open. The hash is cleared before an id is returned.
Fifteen minutes TTL A link is a credential with no owner; the window is what bounds the loss.
One at a time — Issuing again replaces the stored hash, so "send it again" cannot leave two live ways in — the older being the one nobody is watching.
A rate limit RESEND_INTERVAL, MAX_SENDS, SEND_WINDOW The button sits behind a correct password, so anybody who phished one can hold it down.

Two decisions worth knowing before you rely on them:

  • With no authserver.user_activity_log table, a send is allowed. The accounting reads the same rows that audit the sends, and refusing every link when the table is missing would refuse the login itself — a half-migrated installation must not lock everybody out.
  • A failed delivery is not recorded as a send. A dead mail server would otherwise tell the person to watch an inbox nothing is coming to, and spend their rate limit doing it. The stored token is deliberately left behind: it is unreachable, and clearing it would revoke a link an earlier successful send may still have out there.

notifier() is a protected seam, so a test can run send() without a mail server. The same idiom as Controllers\Me::resolveUser() — override it, assert what was handed over.

Fields the users table does not have

User keeps anything outside its own columns in $otherinfo, reached with plain property syntax:

$user->notifyByEmail = '1';
$user->notifyByEmail;                  // '1'
isset($user->notifyByEmail);           // true
$user->neverSet ?? 'default';          // 'default'

All four magic methods read that one store. That is worth stating because the third and fourth lines are not obvious: ?? asks __isset() first and only calls __get() when a class declares no __isset(). A class whose __isset() answered from a different store than its __get() would return the fallback for a value that is present — with no error, no warning, and the value correctly in the database the whole time.

load(null) returns false rather than looking anything up: new User($record->userid) on a record that did not load is a normal path, and 0 already means "load whoever is in the session", which is a different question.

Self-service registration

/register is a real route with the auth feature enabled, and it is closed until you open it:

// app settings
auth_allow_registration = 1

Off is the default because a scaffolded application should not gain a public sign-up page by being upgraded, and most applications on this framework create their accounts by some other route entirely. With it off the page still renders — it says registration is closed rather than 404-ing a page the bundled views link to.

What the endpoint enforces, in this order:

  1. Already signed in → redirected away.
  2. Registration closed → refused before the request body is read at all, so a crafted POST to a closed server cannot write a row. A pending invitation opens it for that one address.
  3. CSRF token → checked before validation, so a form with no token is not even an account-existence oracle.
  4. The human check, when auth.security.human_check lists register. This is a public write that creates a row and sends a mail, which makes it the form on the site most worth pricing.
  5. Username 3–60 characters, [A-Za-z0-9._-] only — the set that needs no escaping in a URL, a log line or an email subject.
  6. A valid email address — with an invitation, the invited address (invite_email_mismatch); without one, an address at a domain in auth_registration_domains when that list is set (email_domain_not_allowed).
  7. The same password policy resetpassword uses: 8 characters, a digit, a symbol, and a matching confirmation.
  8. A unique username (username_taken) and a unique address (email_unavailable), and only then a write.
  9. After the write: an invitation is accepted, or — with auth_registration_verify_email on — a confirmation link is mailed and the account cannot sign in until it is opened (/register/confirmemail). See Registration: open, by domain, by invitation.

A refused submission comes back with the username and the address filled in — retyping an address after a failed check is how people give up — and never with the password: the form is rendered into HTML that ends up in a browser's history, a proxy log and the occasional bug report screenshot.

The new account is active at the lowest privilege level. Nothing in the flow can grant a usertype: createUser() names the five fields it sets, so a submission carrying usertype or userid is ignored rather than obeyed.

What it tells an attacker. "That username is taken" confirms an account exists, and there is no way to both refuse a duplicate and not confirm it — a form that has to let somebody pick another name has to say why. So it reveals that, and the mitigations are the ones that work: leave registration off when you do not need it, and keep the login lockout in place, because the value of an enumerated username is what you do with it next. The email case is worded so that it does not add a second, independent confirmation.

Invitations and a domain allow-list are built in (see Registration: open, by domain, by invitation). To open registration on some other condition — a shared invite code, an organization policy — override one method, and keep the parent's answer so invitation links still work:

class Register extends \Pramnos\Auth\Controllers\Account
{
    protected string $routeBase = 'register';

    public function display()
    {
        return $this->register();
    }

    protected function registrationIsOpen(): bool
    {
        return parent::registrationIsOpen()
            || $this->post('invite') === $this->setting('signup_invite_code');
    }
}

createUser(), usernameExists() and validateRegistration() are seams on the same controller if you need to change how an account is stored or what a username may look like.

The single sign-on status page

/sso answers "does this server already know me, and what have I authorized?". Public, because for a signed-out visitor that negative answer is the useful half — it is what another application sends somebody here to find out.

Creating Users

// Create a new user
$user = new \Pramnos\User\User();
$user->username  = 'johndoe';
$user->email     = 'john@example.com';
$user->firstname = 'John';
$user->lastname  = 'Doe';
$user->active    = 1;
$user->regdate   = time();
$user->save();                      // assigns the userid

$user->setPassword('password1!');   // hashed with the account's pepper, so it needs the id
$user->save();

Two saves, in that order: setPassword() keys the hash with the account's id, so a hash written before the id exists is one no login can match. Assigning $user->password directly stores whatever string you give it.

save() writes active as the object holds it, on every database: 0 and 1, or an application's own states (2, 3, 5…), into the framework's smallint. A PostgreSQL schema that declared it boolean gets a boolean, and one without the column is left alone.

Loading Users

// Load user by ID
$user = new \Pramnos\User\User(123);

// Load user by username or email
$userId = \Pramnos\User\User::getuserid('johndoe', 'username');   // false when there is none
$userId = \Pramnos\User\User::getuserid('john@example.com', 'email');
$user   = new \Pramnos\User\User((int) $userId);

// Get current logged-in user
$currentUser = \Pramnos\User\User::getCurrentUser();
if ($currentUser) {
    echo "Welcome, " . $currentUser->firstname;
}

A user that did not load is a new one

new User($id) for an id with no row gives a new user: save() inserts it, with an id of its own. Check $user->userid > 1 after constructing when the id should exist. A save that finds no row to update — the user was deleted after it loaded — writes nothing, details included, and leaves There is no user N to save: it does not exist. in $user->_errors.

getCurrentUser() is a read

It returns the signed-in user, or false, and writes nothing. Call it as often as a request needs to — the theme header and the controller both asking is the normal case, and the first call caches the answer on the application so the rest are free.

For ?->, use User::current(). getCurrentUser() answers false for nobody, and the nullsafe operator short-circuits only null, so getCurrentUser()?->userid throws Attempt to read property "userid" on false from every cron job and CLI command. User::current() is the same lookup and answers null:

$userId = \Pramnos\User\User::current()?->userid;   // null when nobody is signed in

getCurrentUser() keeps false, because code compares it with === false and would read null as somebody being signed in.

The cache is per request, and a sign-in clears it. Auth::login(), loginById() and logout() all call User::forgetCurrentUser(), so a process that signs in a second user — a test, a worker acting for several accounts — is told about that user, not the first. Code that changes $_SESSION['uid'] by any other route calls User::forgetCurrentUser() itself.

How a stored password is hashed, and read

Everything that hashes or checks a password goes through Pramnos\Auth\PasswordHash. User::setPassword(), User::verifyPassword() and DatabaseAuthDriver all call it, so the front door and a step-up in the middle of an account screen agree about what a stored hash is.

$user->setPassword($plain);            // writes the preferred scheme
$user->verifyPassword($plain);         // true for any scheme it can read

The preferred scheme is bcrypt over an HMAC-SHA-256 digest of the password, keyed by a per-account pepper (md5(securitySalt . userid)). The digest is there because bcrypt stops at 72 bytes: the pepper scheme (still read) appends a 32-character pepper to the plaintext, so everything typed past the 40th character is discarded and two long passwords sharing a 40-character prefix verify against each other. A fixed-length digest has no such ceiling.

The per-account pepper means the same password held by two accounts is two different hashes, so one leaked hash cannot be tested against every row.

verify() reads more than one scheme, and says which

$scheme = \Pramnos\Auth\PasswordHash::verify($plain, $storedHash, $userId);
// 'hmac' | 'pepper' | 'plain' | 'md5' | null
Scheme What it is Accepted
hmac the preferred scheme above always
pepper pepper appended to the plaintext always
plain password_hash($plain, PASSWORD_DEFAULT), no pepper always
md5 a raw md5 from a very old table only with $allowMd5 = true

plain is there because an application sharing this user table may write the row itself, with a bare password_hash(). Without it, such an account could not pass the framework's own password step-up — the correct password would be refused, with nothing in the refusal to suggest that hashing was the reason. Verification reads both, so either side may create a row.

It returns the scheme's name rather than a boolean because a caller cannot know whether to rewrite the row without knowing what it is holding.

Upgrading on the next sign-in

A successful sign-in is the only moment the plaintext exists, so it is the only moment a row can be rewritten in a stronger scheme. That happens automatically, and how far it goes is yours to choose:

// app/app.php
'auth' => [
    'rehash_on_login' => 'modern',   // 'off' | 'modern' | 'all'
],
  • off — never rewrite. For a table whose rows another writer must keep reading in the scheme it wrote them in.
  • modern (the default) — rewrite the pepper-suffix scheme, the preferred scheme when its cost has moved past the stored hash, and md5. md5 needs no second opt-in: a row can only be read as md5 when auth.legacy_md5 is on, which already says the table has md5 rows in it. Leaves a plain password_hash() row alone, because such a row may belong to another application sharing this table, and rewriting it would leave that application unable to verify a password it wrote itself.
  • all — that row too. This is how an application says the table is its own; for migrating one, where the alternative is asking every user to reset a password that is correct.

The same key governs the login driver and User::verifyPassword(), so a sign-in and a step-up never disagree about whether a row may be rewritten. auth.auto_upgrade (boolean) is still read, as an older name for the same decision.

Each rewrite is recorded as a password_hash_upgraded activity entry, so a migration is something you can watch finish rather than assume.

Ordering a step-up: cheap checks first

Where a screen asks for more than one thing — a password and a 2FA code, as disabling two-factor does — check the password first and the single-use code last. Reversed, a mistyped password consumes the code: the user is told their password was wrong, and then has to wait for a new code before they can retype it. The cost only appears in somebody's hands, and the wrong order is the natural one to write.

users.language is the user's preference

The framework never writes users.language on its own — only an operator's edit on the administration user form, or your application. It is yours to interpret — typically as the language the account prefers the interface in.

It is read at sign-in. A login carries the stored language into the session, so the page after the login form is already in it — see Which language a request is served in for the whole order of precedence. Setting the column is all an application has to do.

The framework does not keep it in step with the interface language, and that is deliberate: the two answer different questions. $lang->currentlang() is the language this request is being rendered in, which a ?lang= parameter or a session can change for one page view; users.language is what the account chose. If you want a change of interface language to become the stored preference, write it where the choice is made:

// Wherever your application lets a user pick their language.
$user = \Pramnos\User\User::getCurrentUser();
if ($user) {
    $user->language = $chosen;
    $user->save();
}

One place, visible in a diff, and it does not fire on an account that was merely looked at.

User Data Management

$user = new \Pramnos\User\User(123);

// Get user data as array
$userData = $user->getData();

// Update user information
$user->email = 'newemail@example.com';
$user->save();

// Delete the users row only
$user->deleteuser();

// Erase the account and everything the framework holds about it (GDPR)
(new \Pramnos\Auth\AccountErasure($database))->erase((int) $user->userid);

deleteuser() removes the users row and nothing else. For a person's data, use AccountErasure — see Erasure.

A property that is not a column is a userdetails row. $user->kept_note = 'x' reaches __set() and save() writes it as a userdetails row for that user. A name that begins with _ never is, whether or not it was loaded: it is the place for a subclass's private cache (a loaded model, a computed value), which must not become a row on every save.

userlog is deprecated

userlog holds one row per event about a user. It records the user the row is about but never who acted, so an administrator's change to an account is indistinguishable from the user's own. It is also a second store beside pramnos.changelog_events, which records the entity, the actor and the source, with details as JSON, for users and everything else alike. Two stores for the same events means every reader has to consult both.

  • No framework migration creates it. A new installation has no userlog.
  • An installation that already has it keeps it. The framework never drops it, and the DevPanel's user log shows its rows wherever the table exists.
  • The framework does not write to it. Record user events in changelog_events, through Model::logEvent(), with the changelog feature enabled.

An application that still writes to userlog keeps working on its existing databases. On a new installation it has to create the table in one of its own migrations, or move those writes to changelog_events.

JWT Token Authentication

Generating JWT Tokens

// Create JWT token for user
$payload = [
    'userId' => $user->userid,
    'username' => $user->username,
    'exp' => time() + 3600, // Expires in 1 hour
    'iat' => time(), // Issued at
];

$secret = 'your-secret-key';
$token = \Pramnos\Auth\JWT::encode($payload, $secret, 'HS256');

Validating JWT Tokens

try {
    $secret = 'your-secret-key';
    \Pramnos\Auth\JWT::$leeway = 60; // Allow 60 seconds clock skew

    $decoded = \Pramnos\Auth\JWT::decode($token, $secret, ['HS256']);

    // Token is valid, load user
    $user = new \Pramnos\User\User($decoded->userId);

} catch (\Exception $e) {
    // Token validation failed
    echo "Invalid token: " . $e->getMessage();
}

OAuth2 access tokens get the same 60 seconds. The resource server that OAuth2ServerFactory::createResourceServer() builds accepts a token whose iat or nbf is up to OAuth2ServerFactory::CLOCK_LEEWAY_SECONDS ahead of its own clock. Without that allowance, a token is refused as "not yet valid" whenever the server that checks it is a second behind the one that issued it. That happens with a resource server on another host, or on the same host after its clock is corrected backwards, which a virtual machine's clock routinely is. Expiry is checked with the same allowance.

API Authentication with JWT

// In API controllers, JWT is validated by the auth middleware before the
// controller runs. Ask who the request is — never read $_SESSION.
class ApiController extends \Pramnos\Application\Controller
{
    public function secureEndpoint()
    {
        $user = \Pramnos\User\User::getCurrentUser();
        if (!is_object($user) || (int) $user->userid < 2) {
            return ['status' => 401, 'message' => 'Authentication required'];
        }

        return ['status' => 200, 'data' => 'Protected data for user ' . $user->userid];
    }
}

Do not read $_SESSION in an API controller

An application that serves a website and an API from one origin shares a session cookie between them. $_SESSION['user'] therefore answers with whoever is signed in to the website, which is not the caller — a request that presented no credential at all would be authenticated, and logout could not work, because revoking the token would leave the cookie answering.

User::getCurrentUser() asks the request instead: an API request seals its own identity (see below), and a sealed answer — including "nobody" — stops the session being consulted at all.

Where a request's identity comes from

The framework keeps two ideas apart, and the separation is what makes a hybrid application safe:

website API
credential session cookie token on the call
established by login, stored in the session auth middleware, per request
read with User::getCurrentUser() User::getCurrentUser()
may write the session yes no

Pramnos\Http\RequestIdentity is how a middleware says "this call is user X, or nobody, and nothing else may answer":

RequestIdentity::seal($user, 'accessToken');   // this request is $user
RequestIdentity::seal(null);                   // this request is anonymous

Both ApiAuthMiddleware and UnifiedAuthMiddleware do this for you. Sealing is request-scoped and writes nothing that outlives the call, so an API request can never change who the browser's next page belongs to — and an anonymous API call can never sign the browser out.

An API that genuinely needs both — an authserver whose own web UI calls its own endpoints — should use UnifiedAuthMiddleware, which accepts a Bearer token or a session cookie plus an X-CSRF-Token header. The CSRF token is not optional there: a cookie is sent by the browser automatically, so without it any site could make authenticated calls on the user's behalf.

The data export, section by section

buildExportData() reads a table per section, several belonging to features an installation may not have enabled. Each section is resolved on its own:

foreach ([...'privacy_settings' => fn () => $this->getPrivacySettings($userId)...] as $section => $read) {
    try {
        $export[$section] = $read();
    } catch (\Throwable $unreadable) {
        $export[$section] = [];
        Logger::log('Data export: the ' . $section . ' section could not be read …', 'auth');
    }
}

Each section is read on its own so that one unreadable table cannot abort the whole export. Somebody exercising a data-subject request would otherwise get an error, and every readable section would go with it. For a document an installation is legally obliged to produce, that is the wrong failure: partial is worth more than nothing.

Two details of the recovery are deliberate:

  • The section stays, empty. A missing key reads as this framework has no such concept; an empty one reads as you have none of it. Only the second is something the installation is entitled to say.
  • The failure is logged. In the file, "you have none" and "we could not tell" are indistinguishable — so they have to be distinguishable to the operator, who is the only person who can do anything about it.

A listener contributes its own sections through account.data_export, and cannot overwrite a core one: array_key_exists before writing, so an export's profile always comes from the framework.

Erasure: account.data_erase

The other half of Article 17, and it fires before the framework deletes anything:

\Pramnos\Event\Event::listen('account.data_erase', function (int $userId) {
    // This application's rows for $userId.
    return true;      // false stops the erase, with nothing deleted
});

Before, not after, and that is not a preference. An application's rows almost always carry a foreign key to users; if the framework deleted the user first, its own DELETE FROM users would fail on the children and what is left is an account half erased — which is worse than one that was never started.

A listener returning false stops the erase: Event::fire() short-circuits at the first one, so the listeners after it do not run either, and eraseUserData() raises. deleteaccount() already turns that into an error on the page. Use it for a retention obligation the application cannot ignore, not for "this is inconvenient".

Two things the framework deliberately does not decide:

  • Whose data it is. In a multi-tenant application the rows belong to the organisation, not to the person, and deleting the last member of a tenant is a different act from deleting a colleague's login. There is no default that is right for both, so the listener owns it.
  • What cascades. Nothing cascades into a table with no foreign key, which is most hypertables — a metrics table keyed by a tenant id has no constraint to follow. Delete those explicitly; they are usually the largest thing left behind and the easiest to forget.

What the framework deletes

The erase is Pramnos\Auth\AccountErasure, which Account::eraseUserData() calls and which any code that removes an account can call too:

(new \Pramnos\Auth\AccountErasure($database))->erase($userId);

After the listeners, it deletes the account's rows from each of these tables, explicitly, because none of them has a cascading foreign key to users on every installation:

What Tables
Sign-in usertokens, authserver.user_twofactor, authserver.twofactor_setup, authserver.passkey_credentials, authserver.trusted_devices
Consent and privacy authserver.oauth2_user_consents, authserver.user_privacy_settings, authserver.user_activity_log
What the person held role assignments (Role::assignmentTable()), organisation memberships (Role::membershipTable()), userstogroups
Notifications notifications addressed to a user class, pramnos.pushsubscriptions, pramnos.pushlog, pramnos.pushtests
The assistant chat_conversations with every message in them, chat_memory, chat_asks, ai_consents, user_dashboards; its scheduled tasks (ai_routines) with their runs and readers, and its own report subscriptions. llm_calls keeps each call's cost and loses its userid, prompt, messages and reply
Elsewhere invitations it sent or came from, its mailing-list rows, and finally users

Scheduled tasks meant to outlive the account are handed over first. The delete-account page (Account::deleteaccount()) asks, organisation by organisation, who takes over the person's tasks there. Reports the organisation receives are proposed for the first member listed. The person's own tasks go only if they tick it, and with nobody to take them, they are deleted. The choice travels with the deletion form (tasks[org][to], tasks[org][personal]), and the hand-over runs before the erase, so what was handed over is no longer theirs to delete. See Scheduled tasks.

A table the installation does not have is skipped and logged. A notification addressed to something else with the same id, such as an organisation, is left alone. The change log is not touched: its userid is the author of a change to some other record.

The password-reset token

Auth\Controllers\Account stores it in userdetails rather than in a table of its own — the same shape the new-device auth link uses, and for the same reason: it is the same kind of thing with the same lifetime, and a second mechanism would be a second place to get the expiry check wrong.

Stored password_reset_hash = sha256(token), password_reset_expires = a unix time
Lifetime one hour
Live tokens per account one — upsert on (userid, fieldname), so "send it again" replaces the previous link rather than adding a second
Cleared when the password is actually changed, and when an expired one is resolved

Only the hash is stored. A leaked userdetails row hands out no working links, which matters more here than for most tokens: this one lets the holder choose a new password.

Resolving a token does not spend it

Easy to assume otherwise, and the assumption would be the wrong design:

$userId = $this->consumeResetToken($token);   // resolves — does not clear
// … validate the new password …
$this->updatePassword($userId, $new);
$this->clearResetToken($userId);              // spent here, after the change

resetpassword needs to know whose link it is before it can validate anything. Burning the token at that point would mean somebody who mistypes their confirmation loses their only link and has to start again from the forgot-password form — and the order matters the other way too: the token is cleared after the password is written, so a failed write does not leave an account with neither a password change nor a live link.

The single-use property is real. It lives in the flow, not in the lookup.

The mail is in the recipient's language

Not the language of the request. The forgot-password form can be submitted by anybody from any page — a Greek visitor asking for an English speaker's reset — and the person who reads the mail is the account holder.

\Pramnos\Translator\Language::using($account->language, fn () => $this->composeAndSend(…));

Notifier does this for every notification; this mail is composed by hand, so it asks for the same thing itself. A language the installation has no catalogue for is not switched to and the mail still goes: there being nothing to switch to is not a reason to withhold somebody's reset link.

A catalogue defines $lang; it does not return an array.

<?php
$lang['Password reset'] = 'Αλλαγή κωδικού';

Language::load() includes the file and then checks isset($lang) — so a return [...] file loads without error, defines nothing, and load() answers false. Nothing raises, nothing is logged, and every string renders as itself, which looks like a site written in English rather than a catalogue that was never loaded.

The forgot form answers the same either way

forgotpassword() renders the same "if that address has an account, we have sent a link" whether or not one was found. Any difference — a different message, an error only one path can produce, a redirect on one and a render on the other — turns the form into a way to ask the site who its users are, one address at a time.

The difference is on the inside: a token is written and a mail composed for the address that exists, and nothing happens for the one that does not.

Two refusals come before that, and neither issues anything:

  • the anti-CSRF token, because this form mails an address the submitter chose. Without it, a page anybody writes can make a signed-out visitor's browser ask this site to send mail, as often as it likes;
  • the human check (auth.security.human_check), for the same reason at scale — a form that mails a stranger on demand is the cheapest way to use a site to deliver unwanted mail. The typed address is kept in the re-rendered form: a refusal that also clears the field trains people to give up rather than to try again.

Every refusal in resetpassword() leaves the token usable, and that is a property worth stating because two of them are easy to get wrong in opposite directions:

What happened The link afterwards
no anti-CSRF token still usable — the token is not resolved before the check
password fails the policy still usable — resolved, but only cleared on success
password changed spent
link already spent, or invented nothing to spend

The failure mode of getting either wrong is quiet: the account holder is told "that link is not valid", the link in their mailbox was valid a moment ago, and nothing in the message explains why one mistyped confirmation cost them a second trip through the mailbox.

Who cannot be sent one

findUserIdByEmail() answers null at or below userid 1 — 0 is anonymous and 1 is the system user, which exists to own rows rather than to be signed into. A reset link for it would be a password-choosing link for the account the framework itself acts as.

Token Management

User Tokens

The framework supports various token types for different purposes:

$user = new \Pramnos\User\User(123);

// addToken($tokentype, $token, $notes = '', $parentToken = null, $expires = null)
// returns $user, so calls chain; read tokens back with getToken() / getAllTokens().

// Add authentication token
$user->addToken('auth', bin2hex(random_bytes(32)), 'API access token');

// Add Apple Push Notification token
$user->addToken('apns', $deviceToken, 'iPhone device');

// Add a token issued under a parent token
$user->addToken('access_token', $oauthToken, 'OAuth2 access', $parentToken);

Token Operations

$user = new \Pramnos\User\User(123);

// Get user's active auth token
$authToken = $user->getToken();

// Get all user tokens
$allTokens = $user->getAllTokens();

// Load user by token
$user->loadByToken($tokenString, 'auth');

// Clean up old tokens (older than 30 days)
\Pramnos\User\User::cleanupAllAuthTokens(30);

Web-session tokens, and how they stop

A web_session token is created on every successful web login and lives in $_SESSION['usertoken'], so same-origin AJAX is authenticated without a Bearer token. One login, one row.

That row carries an expiry — 30 days by default, set at creation:

// app settings
'web_session_lifetime' => 2592000,   // seconds; 0 = never expires

Generous next to the PHP session it belongs to, whose own idle timeout (session.gc_maxlifetime) is 24 minutes out of the box, and short enough that the table stops being append-only.

And something retires them. auth:token-cleanup marks every session-bearing token idle for more than a month as status = 2 — kept for the audit trail, no longer accepted — and the framework schedules it daily:

php pramnos auth:token-cleanup             # the same thing by hand
php pramnos auth:token-cleanup --days=90   # be more patient

lastused is updated on every request that presents a token, so "idle for a month" means nothing has used it for a month.

And a login retires what it replaces, immediately — a token superseded a month ago is still a valid bearer credential until the cleanup reaches it, which is not good enough for a credential that has been replaced. createWebSessionToken() retires two things before it writes the new row:

  • the token this request arrived with, from $_SESSION['usertoken'];
  • every other live web_session token from this same browser, matched on the device fingerprint inside deviceinfo.

The fingerprint and nothing else. deviceinfo also carries the address the token was issued from, and matching on the whole stored value would leave the older token valid after a router reboot between two sessions — the dynamic-address problem that SignInFingerprint exists to avoid. Tokens from other devices are left alone: signing in on a laptop must not sign you out on a phone.

loadByToken() reads an expiry of 0 or NULL as "never". Without the expiry and the cleanup, usertokens grows by a row per login for ever — and it is the table tokenactions points a foreign key at.

What a logged request records

Token::addAction() holds a row and the request completes it. Two paths do that:

Path Completed by Records
API updateAction(), once the response is known status, duration, response body
Web the shutdown flush status (http_response_code()), duration since addAction()

The endpoint is stored as a path — /api/v1/stations, not https://host/api/v1/stations?token=…. urls is a deduplicated registry, and a query string that carries an id or a hash gives every call a row of its own. The query is not lost: it goes into params, where a request's inputs belong, whenever params would otherwise be empty — which is every GET.

A negative $return_status still means "this happened, do not record what it returned": the row is written with no status and no duration.

Both matter to the DevPanel's slowest-endpoints report: it needs a duration on every row, and it groups by endpoint, so an absolute URL with its query would make every call an endpoint of its own.

updateAction() repairs a tokenactions older than the columns it writes

An installation whose tokenactions predates return_status, execution_time_ms, return_data and action_time gets them added on the first API call after the upgrade, from inside the catch that handled the failed UPDATE. Then the write is retried.

You need to do nothing. It is documented because of when it runs: once, on a production database, during a request, in an error path — so it is worth knowing it exists, and it is tested on both engines.

Two details, both of the kind only a real database reveals:

  • It asks the schema builder per column and per index. ADD COLUMN IF NOT EXISTS is MariaDB, not MySQL — MySQL 8 rejects it as a syntax error, which inside this catch would fail the request, lose the audit row and leave the table unrepaired.
  • On PostgreSQL the sync trigger treats only servertime > 0 as a real instant. servertime is declared DEFAULT 0, so a row written without one arrives as a zero rather than a null; reading that as a time would stamp the row 1 January 1970, and «no time was given» would be indistinguishable from «this happened at the epoch».

The PostgreSQL branch also installs a plpgsql function and a BEFORE INSERT OR UPDATE trigger that keep servertime and action_time in step both ways, so code that writes only one of them does not leave the other stale. The order inside it is load-bearing: the back-fill from servertime has to happen before action_time becomes NOT NULL, or the ALTER fails on every existing row — after three other ALTERs have already gone through.

deleteToken() revokes one device, and the cascade is MySQL-only

(new User($userid))->deleteToken($tokenid);

Status 2 with a removedate, not a deleted row — a revoked token is evidence. «This credential stopped working at 14:12» is the answer to «was my account used after I signed out of that laptop», and a deleted row cannot answer it.

The WHERE carries the user id as well as the token id, and that is not defensive tidying: token ids are sequential integers that appear in URLs, so a method trusting the id alone would let any authenticated request sign any account out, repeatedly, by counting.

One difference between the engines, and it is deliberate rather than an oversight. On MySQL the revocation also takes any token whose parentToken is the one named; on PostgreSQL it takes only the named token. parentToken is how a refreshed credential remembers the one it replaced, so cascading is the behaviour you want — revoking what a device holds should take the refresh chain hanging off it, or the device renews itself straight back in. If you rely on that, rely on it on MySQL only.

«Sign out everywhere else» keeps the credential you are holding

$user->revokeOtherSessions(md5(session_id()));   // ends every other session and web_session token

revokeOtherSessions(?string $keepSid = null): int spares the caller only when it is given the current session's id (md5(session_id()), as the sessions table stores it). With it, the session row with that id is kept, and so is the web_session token in $_SESSION['usertoken'] — the session is the only place that knows which credential this request arrived with. Called with no argument it ends every session and revokes every web_session token of the account, the caller's included. It returns how many session rows it ended.

Passing the id is what a "sign out everywhere else" button needs: a button that signed the caller out as well is the bug a user notices immediately — they press it, they are ejected, and the natural conclusion is that the button is broken rather than that it worked.

Working with Token Objects

// Create token object
$token = new \Pramnos\User\Token();
$token->userid = $user->userid;
$token->tokentype = 'auth';
$token->token = bin2hex(random_bytes(32));
$token->notes = 'Mobile app access';
$token->expires = time() + (30 * 24 * 60 * 60); // 30 days
$token->save();

// Load existing token
$existingToken = new \Pramnos\User\Token($tokenId);
$details = $existingToken->getDetails();

// Track token usage
$existingToken->addAction(); // Logs the current request

Scopes on a token, and the shape of the column

$token->scope is always a list of strings, whatever the row holds.

$token = new \Pramnos\User\Token($tokenId);

if (in_array('mcp:db_read', $token->scope, true)) {
    // …
}

To write scopes, either set the property and save(), or issue the token in one call:

$user->addScopedToken('access_token', $jwt, ['mcp', 'mcp:logs'], 'notes', $expires);

Four shapes are in that column across installations:

Shape Where it came from
profile email RFC 6749 §3.3, and what AccessTokenRepository writes
["profile","email"] a JSON list, from older Token::save() rows
profile,email comma-separated, from older rows
[profile email] brackets round a space list — a stringified array somewhere upstream

The rule is asymmetric on purpose:

Reading accepts all four, permanently — those rows exist and nobody is migrating somebody else's database
Writing emits the standard, space-separated form only

Token::parseScopes() is the one parser, and it is public so that anything reading the column directly uses the same one rather than writing a second:

$scopes = \Pramnos\User\Token::parseScopes($row['scope']);

Do not explode(' ', …) a raw column value. That reads one of the four shapes, and which one you get depends on how the row was written rather than on anything the caller did. Every reader in the framework delegates to parseScopes(), so one installation gives one answer whichever code path reaches the row first.

Which values get the forgiving parse, and which do not

A column, or a stored token's scope Token::parseScopes()
An OAuth request parameter ($_GET['scope'], the token endpoint's scope) space-delimited, as the RFC defines

The forgiveness exists for rows already in the column. It is not a licence for a client to invent a syntax: a request is a live conversation with somebody who can be told they are wrong, so Scopes::hasInvalidScopes() still reads the standard form only and reports profile,email as one unknown scope.

A value that is not a scope list at all — null, an object, a bare number — parses to an empty list rather than a list containing it. json_decode('123') is a valid document and an int; a scalar where a list belongs would make in_array($needed, $scopes, true) a TypeError on PHP 8 — a 500 where a scope check should be. An empty list makes it a plain refusal.

Permissions System

There is one permission store: authserver.permissions. It is created by the auth feature's migrations, so every installation that has users has it — running an OAuth server is not a prerequisite. Two APIs read it, and which one you want depends on how much of the model you need:

Pramnos\Auth\Permissions Pramnos\Auth\PermissionResolver
Shape one question, one answer the user's whole effective grant list
Reads grants for a user or role grants, roles, priorities, expiry, audience, conditions
Writes yes — allow() / deny() no
Use it for a check in a controller anything conditional, scoped or audited

Permissions is the simple face; it cannot express what it cannot ask about. Grants carrying ABAC conditions are skipped by it rather than treated as unconditional — the resolver hands conditions to the application to evaluate against its own request context, and this API has no way to receive one.

Setting Permissions

allow(), deny() and removePermission() are instance methods; get the shared instance from the factory.

$permissions = \Pramnos\Auth\Permissions::getInstance();

// Grant a user the right to create articles
$permissions->allow(
    $userId,           // Subject — a user id
    'articles',        // Resource      → object_type
    'create',          // Privilege     → action
    '',                // Element: '' means all articles → object_id NULL
    'module',          // Resource type (not stored; see below)
    'user'             // Subject type: user | group
);

// Grant a group — stored as a role, which is what the new model calls it
$permissions->allow($editorsRoleId, 'articles', 'edit', '', 'module', 'group');

// Deny — stored above allow so it wins a tie
$permissions->deny($userId, 'admin', 'access');

// Remove — absence is not the same answer as deny
$permissions->removePermission($userId, 'admin', 'access');

Two mappings worth knowing: the admin privilege is stored as the * action (everything on this object), and resourceType has no column in the new model — object_type already carries that distinction.

A subject type other than user or group cannot be represented. Rather than store it under the wrong subject_type, the call is refused and logged.

Checking Permissions

$permissions = \Pramnos\Auth\Permissions::getInstance();

// Default: an unknown permission is "no"
if ($permissions->isAllowed($userId, 'articles', 'create')) {
    $this->showCreateForm();
} else {
    throw new \Exception('Insufficient permissions', 403);
}

Pass false as the last argument when you need to tell "denied" apart from "nobody ever said":

$verdict = $permissions->isAllowed(
    $userId, 'articles', 'create', '', 'module', 'user', false
);

// true = allowed, false = explicitly denied, null = no rule at all

That distinction matters. Collapsing null into false is how a screen ends up refusing a user for whom no rule was ever written.

Users with a role per organisation. isAllowed() reads the user's roles for the organisation the request is about once the application has set OrganizationScope::resolveWith(); for one named organisation, use isAllowedInOrganization($userId, $organizationId, 'articles', 'create'). See Checking permissions in an organisation.

Roles

The new model expresses groups as roles, and PermissionResolver folds a user's active roles into their effective permissions automatically. Assign one by inserting into authserver.user_roles; role definitions live in authserver.roles.

For the full picture — priorities, expiry, audience scoping and ABAC conditions — use the resolver directly:

$resolver = new \Pramnos\Auth\PermissionResolver($database);
$effective = $resolver->resolve($userId, null)['permissions'];

Each entry carries object_type, object_id, action, grant and conditions, with deny-over-allow already applied.

Session Management

Basic Session Operations

$session = \Pramnos\Http\Session::getInstance();

// Check if user is logged in
if ($session->isLogged()) {
    $userId = $_SESSION['uid'];
    $username = $_SESSION['username'];
}

// Create snapshot for post-login redirect
$session->snapshot($_SERVER['REQUEST_URI']);

// Get and clear snapshot
$returnUrl = $session->getSnapshot();
if ($returnUrl) {
    $this->redirect($returnUrl);
}

Session Security

$session = \Pramnos\Http\Session::getInstance();

// Get session token for CSRF protection
$token = $session->getToken();

// Validate CSRF token
if ($session->checkToken('post', 'csrf_')) {
    // Token is valid, process request
} else {
    // Invalid token, possible CSRF attack
    throw new \Exception('Invalid security token', 403);
}

// Reset session (for logout)
$session->reset();

Handing the browser's user an API token

A hybrid application — a session-authenticated site and a token-authenticated SPA on one origin — has two credentials with two lifetimes. The symptom is always reported the same way: "I am signed in on the site; if I leave it a while and then open the panel, it asks me to log in again." The site knows who they are; the panel has no way to ask.

use Pramnos\Auth\SessionExchange;

$token = SessionExchange::issue(minimumUserType: 90, ttl: 43200);
if ($token === null) {
    return \Pramnos\Http\Response::redirect(sURL . 'login');
}

return \Pramnos\Http\Response::redirect(
    SessionExchange::redirectUrl(sURL . 'panel/', $token)
);

Put that on a session-authenticated route. It answers null when nobody is signed in, when the minimum is not met, or when there is no usable signing key — a route that redirects sensibly either way is the intended caller.

The key it signs with is the API's, not its own front controller's

sURL is dirname(SCRIPT_NAME), so it is not one string: the API front controller (www/api/index.php) sees https://host/api/ and every other request sees https://host/. The signing key is derived from it, so an exchange running on a session-authenticated MVC route derived a different key from the one the API verifies with — every exchanged token refused with 403 InvalidAccessToken, which names neither the key nor the mistake.

Api::baseUrl() is the answer to that: the API's own base in every context — sURL unchanged inside an API request, and sURL plus the API's directory anywhere else. Both SessionExchange and mcp:token derive through it, so a token minted outside an API request verifies inside one.

A project that serves its API from somewhere other than www/api/ says so:

// app/app.php
'api' => [
    'prefix'    => '/api/1.0',
    'directory' => 'api',   // where www/<directory>/index.php lives
],

The directory is deliberately not guessed from prefix: the prefix is a route namespace and the directory is a place on disk, and a project is free to make them differ.

Only a session may be exchanged

A request whose identity was proved by anything other than a session is refused, whatever role it holds.

That is not a formality. User::getCurrentUser() prefers a sealed identity over the session, so without the check an API request carrying a bearer token reached the minting path and received a fresh one — a refresh, with rotation and revocation questions this method answers neither of. Every twelve-hour token good for another twelve hours on request, forever, from a method documented as exchanging a session.

If your application wants refresh tokens, that is a separate mechanism with a separate policy. This is not it.

The signing key

The token is signed with the same key the API verifier uses, resolved in this order:

  1. the current application's authenticationKey, if it declares one — Api computes its own in the constructor, and an application may set one explicitly;
  2. otherwise Api::deriveAuthenticationKey(), which is the identical derivation the API itself performs.

The fallback is the normal path, not the exceptional one, and the reason is worth knowing if you are reading this because an exchange returned null: authenticationKey is declared on Api, not on Application. A session-authenticated MVC route — which is the only kind that can call this — has an Application, so reading the property alone would find nothing.

There is one case with no answer: no declared key and no sURL. The derivation then reduces to md5('edge'), a constant every installation in that state would share, so a token from any of them would verify against all of them. That is refused rather than signed. A real request always has sURL.

A declared key must also be long enough for JWT::encode(), which rejects a short one outright — the exchange reports that as null and logs the reason.

Not UnifiedAuthMiddleware

That solves the other direction: it lets an API endpoint accept a cookie plus a CSRF token. Reaching for it here has a cost worth naming — it makes the API authenticate with cookies, which quietly invalidates every decision an application made because it does not. A permissive CORS default is the usual one, and it was introduced a long way from where it would break.

An exchange goes one way, at one moment, and the API still never reads a cookie.

The four decisions it makes for you

Three are only wrong in ways nobody notices.

Decision Why it is not the caller's to make
The role is re-read from the database, not taken from the session a remember-me cookie can outlive a demotion by a fortnight, and a token minted from that session is then good for its whole lifetime
The token travels in the URL fragment a fragment is never sent to a server. ?token= works, reviews identically, and writes the credential into the access log of every hop and into Referer
Nothing is issued for an anonymous caller, a guest, or user id 0/1 no implicit token, no partial credential; ids 0 and 1 are the guest by convention, and a token minted for one would authenticate as "somebody" everywhere
Failure is null, not an exception the caller is a route that has to redirect somewhere either way

The claim set matches the API login's, so an exchanged token is indistinguishable to every verifier. The row is recorded with notes = 'session_exchange', so a session list can say where the credential came from.

The activity-log entry is written once per session, not once per exchange. Every other entry in that log is a decision somebody made — signed in, changed a password, added a passkey — and each occurrence is worth a row. An exchange is what happens when a page opens without a token, so a new tab, a hard refresh, a private window and an expiry would each produce one, and the entries a person can act on would drown in them. What is audit-worthy is that this session obtained an API credential, and that is one fact however often the page asks again.

Every issued token carries a jti. The other claims — iss, aud, iat, nbf, exp — name no user and are identical for anything minted in the same second, so without it two tokens issued in the same second would be the same string, for two different people as readily as for one. usertokens.token_lookup is unique, so the second insert would fail and that sign-in would do nothing. Nothing verifies the jti; a token is still resolved to its user by its usertokens row.

The one decision that stays yours

An SPA that bounces to the exchange route when it has no token must record that it has bounced before redirecting, not after. The route redirects back without a fragment when it cannot help, so a flag written afterwards is an infinite bounce — on the one page an operator opens when something is already wrong.

And clear the fragment once adopted (history.replaceState), or the token survives in browser history and in whatever a visitor pastes when asking for help.

Requests that are somebody without being an account

RequestIdentity::seal() models an account or nobody. That is right for an API, where anonymous means no identity at all, and not enough for an application whose unauthenticated callers are people: a chat participant with a nickname and a session, present in a room, mutable, bannable, addressable, and the same person across requests for as long as they stay.

RequestIdentity::sealGuest($presenceId, 'presence');

RequestIdentity::isGuest();    // true
RequestIdentity::user();       // null — an account is still an account
RequestIdentity::guestId();    // the opaque id
RequestIdentity::subject();    // the id, whichever kind of identity this is

subject() is the reason to use this rather than a parallel mechanism: one question, one answer, for all three states. Without it an application keeps a second notion of who the caller is, and every consumer has to know which of the two to consult.

The id is opaque and the framework does not interpret it. A presence row, a signed cookie, a hash of a nickname and a session are all yours.

Three rules the framework enforces, because each is only wrong invisibly:

  • A guest never replaces an account. sealGuest() on an already-authenticated request is refused and logged. A middleware that seals a guest unconditionally, ordered after the one that authenticates, would otherwise demote the caller and every later permission check would answer for the wrong person.
  • An account does replace a guest, because that is a real login — and a request must not end up holding both identities.
  • user() keeps returning null. A guest is not a users row, and code asking for a user is not handed something that resembles one. That is why isGuest() is a separate question.

An empty id is refused: every such guest would be indistinguishable, so a mute, a ban or a rate limit keyed on it would apply to all of them at once.

What a session record contains

Every token in usertokens carries a deviceinfo column, JSON-encoded:

{"device": "chrome|windows", "label": "Chrome on Windows", "ip": "203.0.113.9"}

Written for every token type at creation — session, API, OAuth access and refresh — so the active-sessions list can show a person which of their devices a session belongs to.

Token decodes it into an array. Both writers agree on the format — User::addToken() at creation and Token::save() afterwards both json_encode(). The unserialize() branch in Token::fillProperties() is a reader for rows written by an older path, not a second format anything produces today.

It is written once, at creation, and never rewritten. It records the device the token was issued to, which is what makes it comparable later — and what makes a token used from a browser it was not issued to visible as such.

Token::addAction() does not touch it. A column rewritten on use would hold a different shape depending on whether a token had ever been used, the session retirement could not match a used token, and a token used from a browser it was not issued to would have its record rewritten to say the new browser — destroying the evidence it appears to collect.

It stores the fingerprint, not the raw user agent, and that is the same decision as in the section below: keeping the agent string would make every session look like a new device after any browser update, turning a list meant for recognition into a list of strangers. The ip is recorded for an administrator investigating an incident — it is never used to decide anything.

New sign-in alerts

When enabled for an account, a sign-in from a browser/platform combination that account has not used before triggers one email. Whether it is enabled follows the site policy, auth_newsignin_policy (see Telling an account it was used from somewhere new): under optin (the framework default) it is off until the account turns it on; under optout (what pramnos init writes for a new project) it is on until the account turns it off; always and off override the account's choice.

use Pramnos\Auth\NewSignInAlert;

NewSignInAlert::setEnabledFor($userId, true);   // the account's own preference
NewSignInAlert::isEnabledFor($userId);          // the preference read through the site policy

The built-in Account controller exposes it as a checkbox on the privacy page, beside the analytics and marketing consents, in all three scaffolded themes.

What counts as "new", and what deliberately does not

Pramnos\Auth\SignInFingerprint reduces a User-Agent to a browser family and a platform family — chrome|windows, safari|ios — and nothing else.

Signal Used? Why
Browser family yes changes when a person actually uses a different browser
Platform family yes changes when they use a different kind of device
Browser version no Chrome and Firefox ship a major version about every four weeks. Including it is a monthly alarm for every user
Operating system point release no changes without the person doing anything
IP address no dynamic on most consumer connections. An alarm on a changed address fires on a router reboot, and by the second week nobody reads it

The cost, stated plainly: two Chrome-on-Windows machines are indistinguishable, so signing in from a colleague's identical laptop raises nothing. That is the price of an alarm that stays rare, and rarity is the whole value — a security notification people learn to ignore is worse than none. If you need finer granularity, add a signed device cookie; that is the tool built for the job, and narrowing this fingerprint is not.

The current sign-in is excluded once, not everywhere

The history is read after the login lifecycle has logged this sign-in, so the current fingerprint is already in it and would always match itself. It is therefore excluded — but only one row of it.

Excluding every row carrying it looks equivalent and is not. An account that uses a laptop and a phone has both in its history: signing in from the laptop removed all the laptop's rows and left the phone's, which is a non-empty history missing the current device — the definition of "new". So both devices alerted on every single sign-in, forever, which is exactly the alert-nobody-reads failure this feature exists to avoid.

One row is what the current sign-in contributes, so one is what is dropped. If it has not been logged yet, the row dropped is an earlier sign-in from the same device and any others still mark it familiar; only a device being used for the second time ever can still look new, which is the conservative direction.

Where the history comes from

authserver.user_activity_log, which has recorded a user agent against every login since the auth feature shipped.

That is load-bearing rather than incidental. A device detector with no history says everything is new, so on the day it is switched on, every user who opted in is notified at once — about a sign-in they are performing right now. Reading an audit trail that already holds months of user agents means the first sign-in after upgrading is recognised as familiar, which it is.

For the same reason, an account with no history at all is treated as not new.

Only successful login rows count. login_failed carries a user agent too, and letting a failed attempt make a browser familiar would turn the log into a way of switching the alarm off.

Storage

The preference is a userdetails row (fieldname = 'notify_new_signin'), not a column on user_privacy_settings — so no migration is needed and the feature works on every installation the moment the framework is upgraded, including those whose migration_cutoff skips baseline migrations. It inherits that table's cascade on user deletion, which a GDPR-relevant preference needs anyway.

The email

Names the browser and the kind of device, and the time. It does not print the IP address: nobody recognises their own, and printing one invites exactly the compare-with-last-time habit this feature refuses to encourage. The address is in the audit log, for an administrator investigating an incident — the right audience for it.

It offers one action — if this was not you, change your password — and no link. A link in an unexpected security email is the shape of the attack it warns about.

Mail, and a push where the account has a subscribed browser. Not a database notification: that would put the warning in the panel of the session that triggered it, which in the case worth warning about is the wrong person. Push passes the same test — a browser receives one only because somebody granted permission in it earlier, so the subscriptions on an account are the owner's devices rather than the one the sign-in just happened on. See the Web Push Guide for the channel itself.

It is also the one notification the framework sends with an unsubscribe list (unsubscribeList() returns newsignin), because it is the one that belongs to a list: the account can already turn these alerts off on its privacy screen, so honouring an unsubscribe flips that same checkbox. Everything else — a password reset, a security change — is transactional, and offering to unsubscribe from a password reset is offering to disable the only way back into the account.

The URL registry behind token actions

Every token action records which URL the token was used on, and urls is a deduplicated registry — one row per endpoint, not one per call. Token::urlId() resolves a URL to its id, inserting the row the first time that URL is ever seen on the installation.

It is called from the spool drain, which is a long-running process, so the cache in front of it is the design rather than an optimisation: a site serves a few hundred distinct URLs, a worker learns them in its first minutes, and after that every action resolves from memory.

Three properties worth knowing when reading a report built on this table:

  • The cache is bounded at Token::URL_CACHE_LIMIT (2,000), and drops its oldest half when it fills. A site that puts an id in the path or a search term in the query string generates URLs without limit, and a process that never restarts would otherwise grow the cache without limit too. The half that survives is the most recently used, because on a worker those are the URLs it is currently busy with — clearing everything would make the next minute of work re-resolve exactly what it had just learned.
  • The cache is per process; the registry is not. A second worker resolving a URL the first one registered finds the existing row rather than inserting a duplicate, so ids are stable across processes and a report can group on them.
  • An unreachable registry resolves to 0, and the drain carries on. 0 is the "no URL" value the action row takes, so a missing or broken urls table costs the URL of an action rather than the whole batch — a gap in a report instead of a gap in the audit trail.

A consequence for anyone reading urls: a query string is not part of the key. The registry is per endpoint, so /orders?id=1 and /orders?id=2 are one row. That is deliberate — the alternative gave a busy page a new row on every call — and it means a report cannot tell those two apart from this table alone.

Authentication Addons

Addon::load($name) takes a file name or a fully-qualified class name, and refuses anything that is neither — a null or an empty string returns false before the name is used for anything. isActive() and getAddon() do the same.

That is not defensive coding for its own sake: the addons setting holds a serialized list, and an entry saved from an admin screen with no addon selected stores a null name — without the refusal, every request would ask an impossible question per such entry.

User Database Addon

The framework includes a user database addon for standard username/password authentication:

// The UserDatabase addon is automatically triggered during authentication
// It checks credentials against the users table

// Custom validation can be added by extending the addon.
// Auth calls onAuth() with five arguments; the parent declares four, with defaults, so an
// override keeps those defaults and may add `$validate = true` as an optional fifth.
class CustomUserAuth extends \Pramnos\Addon\Auth\UserDatabase
{
    public function onAuth($username, $password,
        $remember = true, $encryptedPassword = false, $validate = true)
    {
        $result = parent::onAuth($username, $password, $remember, $encryptedPassword);

        if (!empty($result['status']) && !$this->isAccountApproved((int) $result['uid'])) {
            return [
                'status'     => false,
                'message'    => 'Your account is awaiting approval',
                'statusCode' => 403,
            ];
        }

        return $result;
    }
}

The second factor does not belong here: the login flow (LoginFlow) asks for it after the password is accepted — see Second factors.

Creating Custom Authentication Addons

class LdapAuth extends \Pramnos\Addon\Addon
{
    public function onAuth($username, $password,
        $remember = true, $encryptedPassword = false, $validate = true)
    {
        // LDAP authentication logic
        $ldapConnection = ldap_connect(
            (string) \Pramnos\Application\Settings::getSetting('ldap_server')
        );

        if (ldap_bind($ldapConnection, $username, $password)) {
            // Authentication successful
            $userInfo = $this->getLdapUserInfo($ldapConnection, $username);

            return [
                'status' => true,
                'username' => $username,
                'uid' => $userInfo['uid'],
                'email' => $userInfo['email'],
                'auth' => password_hash($password, PASSWORD_DEFAULT)
            ];
        }

        return [
            'status' => false,
            'message' => 'LDAP authentication failed'
        ];
    }
}

Controller Authentication

Protecting Controller Actions

class ArticleController extends \Pramnos\Application\Controller
{
    public function __construct(?\Pramnos\Application\Application $application = null)
    {
        // Define public actions (no authentication required)
        $this->addAction(['display', 'view']);

        // Define authenticated actions (login required)
        $this->addAuthAction(['create', 'edit', 'delete', 'save']);

        parent::__construct($application);
    }

    public function display()
    {
        // Public action - anyone can access
        return $this->getView('article')->display();
    }

    public function create()
    {
        // Authenticated action - user must be logged in
        // Framework automatically checks authentication
        return $this->getView('article')->display('create');
    }
}

API Authentication

class ApiController extends \Pramnos\Application\Controller
{
    public function __construct(?\Pramnos\Application\Application $application = null)
    {
        // API endpoints typically require authentication
        $this->addAuthAction(['list', 'create', 'update', 'delete']);
        parent::__construct($application);
    }

    public function list()
    {
        // Who this request is — established by whatever authenticated it, and
        // not by a session cookie the caller never meant as a credential.
        $user = \Pramnos\User\User::getCurrentUser();
        if (!is_object($user) || (int) $user->userid < 2) {
            return ['status' => 401, 'error' => 'Authentication required'];
        }

        // Return data specific to the authenticated user
        return ['status' => 200, 'data' => $this->getUserData($user->userid)];
    }
}

Advanced Features

Login Lockout (Brute-Force Protection)

Pramnos\Auth\Loginlockout — progressive brute-force lockout for login endpoints.

Tracks failed login attempts per scope+identifier pair. Three scopes are supported: 'user' (by user ID), 'identifier' (by normalized email/username), and 'ip' (by remote address).

Default thresholds

Failures Lockout duration
3 60 s (1 minute)
5 300 s (5 minutes)
7 900 s (15 minutes)
10+ 3600 s (1 hour)

A sliding window of 900 seconds applies: if the gap between the previous failure and the current attempt exceeds the window, the counter resets to 1. This prevents indefinite accumulation from past brute-force campaigns.

The OAuth password grant shares the 'identifier' counter with the login form: a wrong password at /oauth/token counts toward it, a success clears it, and a locked account is refused before its password is checked — 400 invalid_grant with a Retry-After header in seconds. Otherwise the token endpoint would be a way round the form's lockout.

Configuring the ladder

Two application settings, both editable from the settings screen:

Setting Meaning
loginlockoutsteps JSON map, {"attempts": seconds} — e.g. {"3":60,"5":300}
loginlockoutwindowseconds the sliding window, 60–86400

Both are read on every attempt, so a change on the settings screen applies to the next failed sign-in.

An unusable loginlockoutsteps (not JSON, empty, or no usable pair) falls back to DEFAULT_STEPS, and a window outside 60–86400 falls back to 900 — never to no lockout. A malformed setting must not be a way to switch brute-force protection off, and a window of zero would reset the counter on every attempt.

Lifting a lockout while developing

The lockout is doing its job when it locks somebody out — and that is no help when you have mistyped a fixture password three times and cannot test the login flow you are working on:

php pramnos auth:unlock admin                # this identifier, every scope
php pramnos auth:unlock 2 --scope=user       # by user id
php pramnos auth:unlock --list               # who is locked, and for how long
php pramnos auth:unlock --all                # everything (development only)

It clears the failure counter and nothing else — a wrong password is still a wrong password afterwards. --all refuses to run outside development: "clear every lockout on this server" is precisely what somebody working through a password list would want.

Four behaviours worth knowing before you rely on them, each one asserted:

  • A scope narrows it. The same value can be locked twice — 10.0.0.5 as an identifier and as an IP — and --scope=ip clears only that one. Without a scope, every scope for that identifier goes.
  • An unknown scope is refused, with the valid ones named. A typo that silently unlocked nothing would read as that account was not locked, and the operator would move on believing the lockout had gone.
  • --list clears nothing, and reports the remaining time in a form somebody reads (1h 1m). "Locked" without a duration does not tell an operator whether to wait or to act, which is the only decision they have.
  • An expired lockout is not listed. The row stays — it is the failure history the progressive backoff counts — but listing it would send somebody to unlock an account that is already usable.

And unlocking something that is not locked succeeds: it is "is this account locked?" asked in the imperative, and the state afterwards is the state the operator wanted either way.

Usage

use Pramnos\Auth\Loginlockout;

$lockout = new Loginlockout();

// Check BEFORE processing the login attempt
$status = $lockout->getLockoutStatus('identifier', strtolower($email));
// Returns: ['locked' => bool, 'remaining' => int]

if ($status['locked']) {
    return ['status' => 429, 'retry_after' => $status['remaining']];
}

// Attempt authentication ...

if ($authFailed) {
    // Record failure for all applicable scopes
    $lockout->recordFailedAttempt('identifier', strtolower($email));
    $lockout->recordFailedAttempt('user', (string) $userId);
    $lockout->recordFailedAttempt('ip', $ipAddress);
} else {
    // Clear state on success
    $lockout->clearSuccessfulLoginState('identifier', strtolower($email));
    $lockout->clearSuccessfulLoginState('user', (string) $userId);
    $lockout->clearSuccessfulLoginState('ip', $ipAddress);
}

API

Method Description
recordFailedAttempt(string $scope, string $identifier): void Increment failure counter; apply threshold; creates row if absent
getLockoutStatus(string $scope, string $identifier): array Returns ['locked' => bool, 'remaining' => int]; 0 remaining when not locked
clearSuccessfulLoginState(string $scope, string $identifier): void Deletes the tracking row — fully resets counter and lockout

Constants

Constant Value
Loginlockout::DEFAULT_WINDOW_SECONDS 900
Loginlockout::DEFAULT_STEPS [3=>60, 5=>300, 7=>900, 10=>3600]

Two-Factor Authentication (TOTP)

Two classes: TOTPHelper (pure static, no DB) and TwoFactorAuthService (stateful, DB-backed).

Compatible with Google Authenticator, Authy, and any RFC 6238 TOTP app.

TOTPHelper — setup and verification

use Pramnos\Auth\TOTPHelper;

// Generate a new shared secret (once per user, during setup)
$secret = TOTPHelper::generateSecret(); // e.g., 'JBSWY3DPEHPK3PXP'

// Verify a user-submitted code (±1 window drift tolerance)
$valid = TOTPHelper::verifyCode($secret, $userCode); // bool

// QR code as an inline data URI — no external API calls, CSP-safe
$dataUri = TOTPHelper::getQRCodeDataUri($secret, 'user@example.com', 'MyApp');
// Returns 'data:image/svg+xml;base64,…' (requires chillerlan/php-qrcode ^5.0) or null

// Build the provisioning URI
$uri = TOTPHelper::buildOtpAuthUri($secret, 'user@example.com', 'MyApp');
// 'otpauth://totp/MyApp:user%40example.com?secret=…&issuer=MyApp'

// Backup codes
$codes   = TOTPHelper::generateBackupCodes(10);        // ['ABCD2345', ...]
$hash    = TOTPHelper::hashBackupCode($codes[0]);       // bcrypt hash for storage
$isMatch = TOTPHelper::verifyBackupCode($code, $hash);  // bool, case-insensitive

// Utilities
$remaining = TOTPHelper::getRemainingTime();    // seconds until window expires [1, 30]
$isValid   = TOTPHelper::isValidSecret($secret); // validate base32 format

Security note: getQRCodeUrl() sends the TOTP secret to an external API and is deprecated. Always use getQRCodeDataUri() instead.

TwoFactorAuthService — full setup flow

use Pramnos\Auth\TwoFactorAuthService;

$svc = new TwoFactorAuthService(); // uses Factory::getDatabase()

// Step 1 — generate secret and show QR code to user
$info = $svc->startSetup($userId, $userEmail);
// ['secret', 'qr_code_data_uri', 'qr_code_url', 'manual_entry_key']
// No backup codes here — see below

// Step 2 — user scans QR, enters first code to confirm
$success = $svc->completeSetup($userId, $submittedCode); // bool

// Step 3 — NOW show the backup codes, once
$codes = $svc->takeNewBackupCodes();       // string[]; [] when there are none

// Verify on login
$valid = $svc->verifyCode($userId, $code); // accepts TOTP or backup code

// State queries
$enabled   = $svc->isEnabled($userId);   // bool
$remaining = $svc->getRemainingBackupCodes($userId); // int
$status    = $svc->getStatus($userId);
// ['enabled' => bool, 'setup' => bool, 'backup_codes_remaining' => int]

// Management — the password is required; the unchecked path has its own name
$svc->disable($userId, $password);                            // refused when wrong or empty
$newCodes = $svc->regenerateBackupCodes($userId, $password);  // string[]|false
$svc->disableForOperator($userId);                            // administrative
$svc->regenerateBackupCodesForOperator($userId);              // administrative, destructive
$svc->cleanupExpiredSessions();                               // removes used/expired setup rows

The issuer is the name the authenticator app files the account under. startSetup() takes it as a third argument and defaults to Pramnos, which names the framework rather than the site. The TwoFactorAuth controller passes auth_brand_name, then sitename, then the application's name from app.php — the same order the sign-in card brands itself with, so the name on the card and the name in the app agree. Override issuer() to choose another. It is only a label: changing it later renames nothing in an app that has already scanned the code.

Backup codes come from enrolment, not from setup. startSetup() deliberately returns none: completeSetup() generates and stores the account's codes, so they can only be shown after enrolment — codes shown any earlier would not be the ones stored. takeNewBackupCodes() returns what enrolment stored, once (it survives one redirect, in the session, and is cleared on read).

It is also the right moment to show them: somebody who abandons setup halfway should not walk away holding recovery codes for an account with no second factor.

The password is required, and the unchecked path has a name. disable() and regenerateBackupCodes() both take the account password and verify it. Without that check, any signed-in session could strip the second factor, or rotate the backup codes (which invalidates every code the owner wrote down and prints ten new ones to whoever asked).

Call Meaning
disable($userId, $password) the user's own action — refused on a wrong or empty password
disableForOperator($userId) administrative — an operator clearing 2FA off an account whose owner cannot reach it
regenerateBackupCodes($userId, $password) the user's own action
regenerateBackupCodesForOperator($userId) administrative, and destructive: it invalidates every code the owner holds

The password is not optional, deliberately. PHP discards an extra argument to a userland function, and with an optional parameter a caller that omits it would silently skip the check. A step-up check in front of removing the second factor is not something to skip by accident, so skipping it has to be spelled. An empty string counts as wrong rather than absent, so a form that submitted nothing does not pass.

Whoever exposes the …ForOperator methods decides who may call them; they are not the user's own action, and the codes the second one returns have to reach the account's owner rather than the operator.

Replay protection: verifyCode() compares the current 30-second window against last_used. If the same window was already used, the code is rejected even if cryptographically valid.

Backup codes are one-time: the matching hash is removed from storage after successful verification.

Database tables

Table Description
authserver.user_twofactor One row per user — enabled flags (TOTP and email), secret, backup code hashes, last_used
authserver.twofactor_setup Temporary setup sessions (15-min TTL)
authserver.twofactor_attempts Append-only attempt log (TimescaleDB hypertable where available)
authserver.twofactor_email_codes Live mailed codes for the email factor — one row per user per purpose, hashed, with an expiry and an attempt cap

Password Security

There is nothing to override. User::setPassword() hashes with the preferred scheme and User::verifyPassword() reads every scheme PasswordHash knows (see How a stored password is hashed, and read). Replacing either with a plain password_hash() / password_verify() pair would stop the account's existing peppered hashes from verifying, and verifyPassword(string $password): bool cannot be overridden without its return type in any case.

The password policy — 8 characters, a digit, a symbol, a matching confirmation — is Account::validatePasswordPolicy(), used by registration, the password change and the reset alike. Override it on your Account subclass to change the rules in all three places.

OAuth2 Scopes and Policy

Pramnos\Auth\Scopes — static scope registry for OAuth2 consent screens and validation.

Your own catalogue

Subclass, override getScopes(), and name the class in app.php:

// app.php
'scopes' => ['provider' => \App\Scopes::class],

The second half is not optional. Every internal call in Scopes is static::, so the subclass answers consistently for anything that names it — but ten sites in the framework write Scopes:: literally, and those are the ones that face outward:

Discovery ×4 scopes_supported in the /.well-known/ documents
Oauth::authorize() whether a requested scope exists
Oauth consent view the description shown beside each grant
OAuth2\Repositories\ScopeRepository whether a scope is registered at all
Mcp\PublicRegistry which tools a token's scopes reach
mcp:token, init validation, and what the scaffolder offers

Without the config line an installation can define forty-four scopes, publish fifteen, render a consent screen with no description beside what the user is being asked to grant, and have its own identifiers refused as unknown.

A class that is not a Scopes subclass is refused with an exception rather than ignored: a misspelt name that was quietly dropped is the same failure by a different route.

Replace or merge — say which, in code

The framework cannot pick, because a catalogue is not reliably a superset. The subclass says it:

// Merge — the framework's standard scopes, plus yours
public static function getScopes(): array
{
    return parent::getScopes() + ['Devices' => [/* … */]];
}

// Replace — this installation's vocabulary is the whole vocabulary
public static function getScopes(): array
{
    return ['Devices' => [/* … */]];
}

Replacing drops openid, profile, email and offline_access unless you declare them, and the OAuth2 server needs those to answer an OIDC request. Merging is the safer default; replacing is the honest one for an installation that has curated its own list.

The MCP scopes are appended either way. They are derived from what PublicRegistry actually offers rather than declared anywhere, so a catalogue written before the endpoint existed cannot make it ungrantable.

use Pramnos\Auth\Scopes;

// All scopes grouped by category (consent screen)
$grouped = Scopes::getScopes();
// ['Personal User Data' => ['profile' => [...], 'email' => [...]], ...]

// Flat scope → description map
$descriptions = Scopes::getScopeDescriptions();

// Scopes implicitly granted to all clients
$defaults = Scopes::getDefaultScopes(); // ['profile', 'email', 'user']

// Validate a scope string
[$hasInvalid, $invalidList] = Scopes::hasInvalidScopes('profile email unknown_scope');

// Resolve inherited scopes transitively
$resolved = Scopes::resolveInheritedScopes('system:notifications_write');
// ['system:notifications_read', 'system:notifications_write']

Standard scopes: profile, email, phone, address, user, openid, offline_access, system:admin, system:audit_read, system:health, system:notifications_read/write.

Pramnos\Auth\OAuthPolicyHelper — server-wide OAuth2 policy defaults.

use Pramnos\Auth\OAuthPolicyHelper;

$methods = OAuthPolicyHelper::getDefaultAllowedAuthMethods();
// ['client_secret_basic', 'client_secret_post', 'private_key_jwt']

$grants = OAuthPolicyHelper::getDefaultAllowedGrantTypes();
// ['authorization_code', 'client_credentials', 'device_code', 'password', 'refresh_token', 'exchange_token']

These are the grants a client with no rows of its own in oauth2_application_grants may use. The list includes password. To drop it — OAuth 2.1 deprecates the grant — set the list in app.php; pramnos init writes this key for an authserver project:

'authserver' => [
    'default_grants'       => ['authorization_code', 'client_credentials', 'device_code', 'refresh_token', 'exchange_token'],
    'default_auth_methods' => ['client_secret_basic', 'client_secret_post', 'private_key_jwt'],
    'default_scopes'       => [],
],

jwt_bearer is never a default grant, and a system: scope is never a default scope: either is ignored with a warning if listed.

OAuth2 Integration

The OAuth2 server is built in — do not hand-roll an authorize() action. With the authserver feature, Pramnos\Auth\Controllers\Oauth serves the authorize, token, revoke, introspect, userinfo, logout, client-registration and device-authorization endpoints on league/oauth2-server (Pramnos\Auth\OAuth2\OAuth2ServerFactory), with PKCE, consent, client authentication and per-client grant and scope policy. Authorization codes are stored by AuthCodeRepository, not as user tokens. How a client application registers and signs in is in the Third-Party Integration Guide; signing in to another platform is the OAuth2 Client Guide.

Registration: open, by domain, by invitation

Self-service registration at /register is off unless something opens it. Three things can:

Both settings below are fields in System Settings → Security → Registration, unless app.php keeps them: 'auth' => ['registration_admin_editable' => false] shows them read-only (RegistrationPolicy::editableInAdmin()). A value in app/config/settings.php still wins over the screen, as for every setting.

Setting Effect
auth_allow_registration open to anybody
auth_registration_domains with the above, only addresses at these domains — example.com, example.org; whole domains, so evil-example.com is not example.com
an invitation open for one address, while everything else stays closed

Confirming the address. auth_registration_verify_email holds a self-registered account back until it follows a link mailed to it (register/confirmemail, valid auth_email_verify_ttl_hours, 48). It is always on when a domain list is set — otherwise anybody could type an address at the domain and hold an account relying parties believe belongs to it. Until then sign-in answers email_unverified rather than signing in, and signing in again after ten minutes mails a new link. Only accounts that were held back this way are stopped; an older account with validated = 0 is not. validated is what the ID token's email_verified is read from, so it is true only when somebody checked.

Invitations

use Pramnos\Auth\Invitations;

$made = (new Invitations())->invite('maria@example.com', [
    'invitedBy'      => $admin->userid,
    'organizationId' => 7,        // joins on acceptance
    'roleId'         => 3,        // and is given this role
    'note'           => 'Finance team',
    'metadata'       => ['plan' => 'gold'],
]);
$made['link'];   // https://…/register?invite=… — shown once; only Token::lookup() of it is stored
  • The link opens registration for that address only (invite_email_mismatch otherwise), and following it confirms the address, so an invited account is not asked to.
  • It works once — the row is claimed by an update conditioned on it being unused, so two registrations racing on one link cannot both succeed — and for auth_invitation_ttl_hours (48).
  • One live invitation per address: a new one withdraws the old. None for an address that has an account. A role belonging to an organisation goes only with an invitation into it.
  • resend() issues a new token (the old one cannot be recovered from its hash) and revoke() withdraws; all() lists with a state — waiting, accepted, revoked, expired.
  • Accepting fires invitation.accepted with the row and the new account's id — the place for whatever an application attached as metadata:
\Pramnos\Event\Event::listen(Invitations::EVENT_ACCEPTED, function (array $invitation, int $userId): void {
    $extra = json_decode((string) $invitation['metadata'], true) ?? [];
    // …
});

The Invitations administration screen (admin.invitations; usertype 98 under admin_access = usertype) invites, shows the link once, resends and withdraws. Only somebody who may open Roles can attach a role. The mail's text is the auth.invitation template; the confirmation mail's is auth.verify_email.

Attaching your own data from the screen

The screen passes invitedBy, organizationId, roleId and note. To attach metadata (or any other option invite() takes), override inviteOptions() in your wrapper controller and add the form fields by overriding the invitations view in your theme:

class Invitations extends \Pramnos\Auth\Controllers\InvitationsController
{
    protected function inviteOptions(): array
    {
        return ['metadata' => ['capabilities' => array_keys((array) ($_POST['off'] ?? []))]];
    }
}

What it returns is merged under the screen's own options, so it cannot change who invited, the organisation, the role or the note — those the screen decided and checked.

The mail

The auth.invitation template gets {link}, {hours}, {days}, {inviter}, {note} and {sitename}. {days} is the whole number of days when the lifetime is one (a 21-day link is 21, where {hours} says 504), otherwise the hours as a fraction of a day; the built-in text says days whenever it can.

A stored template has no preheader, so without one the mail client shows the body's first sentence. To choose it, override notification() in a subclass of Invitations — it builds the mail from the link and the stored row, metadata included — and return it through your controller's service():

class Invitations extends \Pramnos\Auth\Invitations
{
    protected function notification(string $link, array $row): InvitationNotification
    {
        return parent::notification($link, $row)
            ->withPreheader('Publishing and analytics for your channels. Your link is inside.');
    }
}

When an account is erased

Account::eraseUserData() calls Invitations::forgetUser(). The invitations the account sent that nobody accepted hold other people's addresses and are deleted; the ones that were accepted stay, with invited_by cleared, because they belong to the account they created; the invitation the erased account itself came from is deleted.

Configuration

Authentication Settings

Two places configure authentication, and each key is described in the section that owns it.

Application settings — editable on the settings screen, or fixed in app/config/settings.php:

Setting Section
web_session_lifetime Web-session tokens
loginlockoutsteps, loginlockoutwindowseconds Login Lockout
auth_allow_registration, auth_registration_domains, auth_registration_verify_email, auth_invitation_ttl_hours Registration
auth_newsignin_policy, auth_newsignin_action, auth_newsignin_trigger Telling an account it was used from somewhere new
auth_trusted_devices, auth_trusted_device_days, auth_push_approval, auth_push_number_matching Trusted devices and sign-in approval

app/app.php — fixed per deployment, versioned with the code:

'auth' => [
    'rehash_on_login'             => 'modern',   // 'off' | 'modern' | 'all'
    'newsignin_policy'            => 'optout',   // the default the setting overrides
    'registration_admin_editable' => true,
    'security'                    => ['human_check' => ['register']],
],
'authserver' => [
    'default_grants'       => [...],
    'default_auth_methods' => [...],
    'default_scopes'       => [],
],

See Upgrading on the next sign-in and OAuth2 Scopes and Policy for the rehash_on_login and authserver keys.

Email confirmation at registration is a setting rather than a configuration key — see Registration.

Database Setup

The tables are created by the framework's migrations — there is nothing to create by hand:

php <cli>.php migrate --scope=framework

The auth feature creates users, usertokens, authserver.permissions and the other authserver.* tables this guide names; the authserver feature adds the OAuth2 server's own. Do not build these tables from hand-written SQL: usertokens.token holds an encrypted value and authentication finds a token through usertokens.token_lookup, which a hand-made table would not have.

If your installation has a hand-made permissions table, see Legacy Permissions Migration for the SQL that moves its rows into authserver.permissions.

Best Practices

Security Guidelines

  1. Use the query builder - $db->queryBuilder() binds parameters instead of interpolating them, and resolves authserver.* table names per driver
  2. Validate JWT tokens properly - Set appropriate expiry times and validate all claims
  3. Use strong passwords - Implement password complexity requirements
  4. Enable CSRF protection - Use session tokens for form submissions
  5. Implement rate limiting - Prevent brute force attacks on login endpoints
  6. Use HTTPS - Always transmit authentication data over secure connections
  7. Log security events - Monitor failed login attempts and permission violations

Performance Considerations

  1. Cache permissions - Use the caching system for frequently checked permissions
  2. Optimize token queries - Index token tables properly
  3. Clean up expired tokens - Regularly remove old authentication tokens
  4. Use efficient session storage - Consider Redis for session storage in production

Error Handling

try {
    $auth = \Pramnos\Auth\Auth::getInstance();
    $result = $auth->auth($username, $password);

    if (!$result) {
        $response = $auth->lastResponse;
        \Pramnos\Logs\Logger::log(
            'Failed login attempt for ' . $username . ': ' . ($response['message'] ?? ''),
            'auth'
        );

        return ['status' => 401, 'message' => 'Authentication failed'];
    }

} catch (\Throwable $e) {
    \Pramnos\Logs\Logger::logError('Authentication error', $e);   // message, then the exception

    return ['status' => 500, 'message' => 'Internal authentication error'];
}

This authentication system provides a robust foundation for securing your Pramnos Framework applications with support for modern authentication patterns, comprehensive user management, and flexible permission systems.



For additional information on implementing authentication in your controllers and APIs, see the Framework Guide section on authentication patterns.