Pop PHP
The Toolkit

Authentication

Authentication answers one question: is this person who they claim to be. What they're then allowed to do lives in Authorization. Pop splits the first question across two components — pop-auth holds a file adapter and a JWT adapter, and pop-db holds the record class that authenticates against a users table.

BASH
composer require popphp/pop-auth

Choosing an Approach#

Three approaches ship, and they are not variations on one theme — they answer the question with different material and belong in different kinds of application.

Approach Class Credential Reach for it when
Database Pop\Db\Record\Auth username and password, in a table You own the users. Almost every application with a login form.
File Pop\Auth\File username and password, in a text file A handful of fixed accounts and no database — a deploy tool, an internal endpoint.
JWT Pop\Auth\Jwt a bearer token someone else signed You are an API accepting tokens from an identity provider.

The first two verify a secret you store. The third verifies a signature over a token you do not store at all, which is what makes it the stateless option — there's no lookup, no session, and nothing to keep in sync across servers.

Pop\Auth\File and Pop\Auth\Jwt share Pop\Auth\AuthInterface, so they are interchangeable at the call site: authenticate() returns 1 for a match and 0 for a failure — AbstractAuth::VALID and AbstractAuth::NOT_VALID — and isAuthenticated() re-reads that result later without re-running the check. Pop\Db\Record\Auth does not implement that interface; it's a record, and it returns a boolean or the record itself.

v7 moved table-backed authentication to pop-db as Pop\Db\Record\Auth, which gained lockout and MFA with it. For delegated auth over HTTP, use the client's own authentication — see HTTP Client.

A returned 0 means the credentials were checked and didn't match. Pop\Auth\Exception is thrown only when the adapter can't perform the check at all — a missing access file, unusable key material — so catch the exception rather than treating every non-1 result the same way:

PHP
use Pop\Auth\File;

try {
    $auth = new File('/path/to/.htmyauth');

    if ($auth->authenticate('admin', 'password')) {
        // authenticated
    } else {
        // the credentials did not match
    }
} catch (Pop\Auth\Exception $e) {
    // the check could not be performed at all
}

Database-Backed Auth#

A users table is the usual case, and Pop\Db\Record\Auth is a record class, not an adapter. Your table class extends it, and the login is one call on an instance:

PHP
use App\Table\Users;

$user = new Users();

if ($user->authenticate($username, $attemptedPassword, false)) {
    // $user is now loaded with the matched row
}

That third false turns off multi-factor. Left at its default, a correct password returns the loaded record with a fresh code written to it for you to deliver. The columns the class needs, the attempt lockout, the five failure constants and the MFA round trip are all in Auth Records.

What that page leaves to this one is what happens next. authenticate() proves the credentials for exactly one request; a browser sends the next request with no memory of it, so something has to carry the fact forward. That something is the session:

PHP
use App\Table\Users;
use Pop\Session\Session;

$user = new Users();

if ($user->authenticate($username, $attemptedPassword, false)) {
    $session = Session::getInstance();
    $session->user_id  = $user->id;
    $session->username = $user->username;
}

Store the identifier, not the record. A serialized record goes stale the moment the row changes, and a permissions field cached in a session outlives the change that was supposed to revoke it — re-fetch the row on the requests that need it.

Later requests read $session->user_id to decide whether anyone is logged in, which is a job for middleware rather than a check repeated in every controller — see Middleware. Logging out is Session::getInstance()->kill(), which destroys the session rather than unsetting the one key, so nothing else you stored survives it either.

A locked account stays locked until something calls resetAttempts(), so build the unlock path you want — an admin screen, an emailed link, or a scheduled task that clears attempts after a window.

File-Based Auth#

Pop\Auth\File reads a colon-delimited file in the shape Apache's .htpasswd uses — one account per line, username first, hash second.

TEXT
admin:$2y$12$EJE7Ryk/W0tBtnRa5NdK/.FlfWLl.Vn6oSRemggdd7BVL0KpXb92S
deploy:$2y$12$E2u2EOsGVKdMQfxnRPkzaevEZGAORhW2UtrhbPaejRswrwcUx9p5u

Point the adapter at the file and authenticate:

PHP
use Pop\Auth\File;

$auth = new File('/path/to/.htmyauth');

$auth->authenticate('admin', 'password'); // 1
$auth->isAuthenticated();                 // true
$auth->getUsername();                     // 'admin'

The file is read on every authenticate() call, so edits take effect without a restart. The constructor checks the path immediately — a file that doesn't exist throws Pop\Auth\Exception there rather than at the first authentication attempt.

pop-auth never writes to the file. Generating the hashes is your job, and password_hash($password, PASSWORD_DEFAULT) produces exactly what the adapter expects — see Hashing & Encryption.

A third field per line scopes an entry to a realm, and the adapter matches it only when you configure one:

PHP
use Pop\Auth\File;

$auth = new File('/path/to/.htmyauth', 'example.com');

$auth->authenticate('admin', 'password'); // 1 for a line reading admin:example.com:<hash>

Configure the realm on every adapter reading a file that has one. A realm-configured adapter still authenticates a two-field line with no realm, but an adapter with no realm returns 0 for a three-field line. A third constructor argument replaces the delimiter, for files built around pipes or tabs.

needsRehash() reports, after a successful authentication, whether the stored hash should be upgraded — it returns true both for a hash made at an outdated cost and for an entry stored as plain text, which the adapter still accepts. Acting on it means rewriting the file yourself:

PHP
use Pop\Auth\File;

$auth = new File('/path/to/.htmyauth');
$auth->authenticate('admin', 'password');

if ($auth->isAuthenticated() && $auth->needsRehash()) {
    $newHash = password_hash('password', PASSWORD_DEFAULT);
    // rewrite the line yourself — pop-auth never writes to storage
}

The flag is per-attempt, not sticky: a later failed authenticate() on the same object resets both isAuthenticated() and needsRehash() to false, so read it in the same branch that saw the success.

JWT#

Pop\Auth\Jwt verifies a bearer token instead of a stored secret. There's no user lookup and no session — the token carries its own claims, and the signature is what makes them trustworthy. That makes it the adapter for an API accepting tokens minted somewhere else.

It's a verifier only. pop-auth does not issue tokens; the identity provider that holds the signing key does that.

PHP
use Pop\Auth\Jwt;

$auth = new Jwt('HS256', $secret);

$auth->authenticate($token); // 1

if ($auth->isAuthenticated()) {
    $claims = $auth->getUser(); // ['sub' => 'admin', 'exp' => 1787584364, ...]
}

Three algorithms are supported. HS256 takes a shared secret, which means anyone who can verify a token can also forge one — acceptable when the issuer and the verifier are the same application. RS256 and ES256 take a PEM-encoded public key, so the verifier holds nothing that can mint a token:

PHP
use Pop\Auth\Jwt;

$auth = new Jwt('RS256', $publicKeyPem);

$auth->authenticate($token);

The algorithm is fixed at construction rather than read from the token, which is what keeps an attacker from presenting one signed with an algorithm of their choosing.

exp and nbf are checked whenever the token carries them. aud and iss are not checked unless you configure them, which is a decision worth making deliberately: a token issued for a different service, or by a different provider, verifies perfectly well against a shared key unless you say what you expect.

PHP
use Pop\Auth\Jwt;

$auth = new Jwt('HS256', $secret);

$auth->setAudience('my-api')
    ->setIssuer('https://auth.example.com')
    ->setLeeway(30);

$auth->authenticate($token);

setLeeway() takes seconds and absorbs clock skew between the issuer and you, in both directions — a token that expired ten seconds ago verifies under a leeway of thirty. Keep it small. An aud claim that is an array matches when the configured value is one of its members.

Anything wrong with the token returns 0 rather than throwing: a wrong secret, a mismatched key pair, an edited payload, a string that isn't three segments, an expired exp, an unexpected aud. getUser() returns null after any of them.

The one case that throws is unusable key material. new Jwt('RS256', 'not-a-real-key') constructs without complaint and then throws Pop\Auth\Exception from authenticate(), wrapping the OpenSSL error text. That's a deployment problem, not a rejected request, and it deserves a different response than 0 does.

needsRehash() is always false on this adapter. It's part of the shared interface and means nothing when there's no stored hash to upgrade.

The adapters carry more surface than this page covers — the full accessor set, AdapterUserTrait's configurable username and password fields — see the pop-auth README.

See Also#