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.
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:
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:
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:
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.
admin:$2y$12$EJE7Ryk/W0tBtnRa5NdK/.FlfWLl.Vn6oSRemggdd7BVL0KpXb92S
deploy:$2y$12$E2u2EOsGVKdMQfxnRPkzaevEZGAORhW2UtrhbPaejRswrwcUx9p5u
Point the adapter at the file and authenticate:
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:
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:
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.
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:
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.
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#
- Auth Records —
Pop\Db\Record\Auth, lockout and MFA in full - Authorization — what an authenticated user is allowed to do
- Hashing & Encryption — producing the hashes these adapters compare against
- HTTP Client — sending credentials outward, and the replacement for the removed
Httpadapter - Sessions & Cookies — carrying a login across requests
- pop-auth README — the full API surface