Authentication
Signing users in and out, password hashing, guarding routes, and throttling brute force.
Four pieces: an Identity (your user), a UserProvider (how to find one), a PasswordHasher, and an Authenticator that ties them to the session.
Your user
<?php
namespace App\Models;
use PhpOrbit\Auth\Identity;
final class User implements Identity
{
public function __construct(
public readonly int $id,
public readonly string $email,
public readonly string $displayName,
private readonly string $passwordHash,
) {
}
public function authIdentifier(): string
{
return (string) $this->id;
}
public function passwordHash(): string
{
return $this->passwordHash;
}
}Finding one
<?php
namespace App\Auth;
use PhpOrbit\Auth\Identity;
use PhpOrbit\Auth\UserProvider;
use PhpOrbit\Database\Connection;
final class DatabaseUserProvider implements UserProvider
{
public function __construct(private readonly Connection $database)
{
}
public function findByIdentifier(string $identifier): ?Identity
{
if (preg_match('/^\d+$/', $identifier) !== 1) {
return null;
}
return $this->hydrate(
$this->database->query('users')->where('id', '=', (int) $identifier)->first(),
);
}
public function findByEmail(string $email): ?Identity
{
return $this->hydrate(
$this->database->query('users')->where('email', '=', strtolower(trim($email)))->first(),
);
}
public function updatePasswordHash(Identity $user, string $hash): void
{
$this->database->query('users')
->where('id', '=', (int) $user->authIdentifier())
->update(['password_hash' => $hash]);
}
/**
* @param array<string, scalar|null>|null $row
*/
private function hydrate(?array $row): ?User
{
return $row === null ? null : new User(
(int) $row['id'],
(string) $row['email'],
(string) $row['display_name'],
(string) $row['password_hash'],
);
}
}Signing in
<?php
final class LoginAttemptController implements Handler
{
public function __construct(
private readonly Authenticator $auth,
private readonly LoginThrottle $throttle,
private readonly Session $session,
private readonly TemplateEngine $view,
) {
}
public function handle(ServerRequest $request): Response
{
$email = $request->form('email') ?? '';
// You build the key. See "Throttling" below for why it combines both.
$key = mb_strtolower($email) . '|' . ($request->headers->first('X-Forwarded-For') ?? 'local');
if ($this->throttle->tooManyAttempts($key)) {
return $this->view->respond('login', [
'title' => 'Sign in',
'error' => sprintf('Too many attempts. Try again in %d seconds.', $this->throttle->retryAfter($key)),
], Status::TooManyRequests);
}
if (!$this->auth->attempt($email, $request->form('password') ?? '')) {
$this->throttle->record($key);
// Never say which of the two was wrong.
return $this->view->respond('login', [
'title' => 'Sign in',
'error' => 'Those credentials do not match.',
], Status::UnprocessableEntity);
}
$this->throttle->clear($key);
return Response::redirect($this->session->takeFlash(RequireAuthentication::INTENDED_KEY) ?? '/');
}
}attempt() does the rest: verifies the password, regenerates the session id, rotates the CSRF token, and rehashes the password if the parameters have changed since it was stored.
The Authenticator
<?php
$auth->check(); // bool — is someone signed in?
$auth->guest(); // the inverse
$auth->user(); // ?Identity — re-read per request
$auth->attempt($email, $password); // bool
$auth->login($user); // sign in an Identity you already have
$auth->logout(); // sign outOnly the identifier goes into the session; the user is loaded per request. A deactivated or deleted account therefore loses access on its very next request, rather than whenever the session happens to expire.
Guarding routes
<?php
use PhpOrbit\Auth\RequireAuthentication;
$routes->withMiddleware([new RequireAuthentication('/login')], static function (RouteCollection $routes): void {
$routes->get('/account', AccountController::class, 'account');
$routes->post('/account', UpdateAccountController::class, 'account.update');
});Guests are redirected, and where they were heading is flashed so they land there after signing in:
<?php
$intended = $session->takeFlash(RequireAuthentication::INTENDED_KEY) ?? '/';Password hashing
<?php
$hasher = new PasswordHasher();
$hash = $hasher->hash($plain); // Argon2id where available, bcrypt otherwise
$hasher->verify($plain, $hash); // bool
$hasher->needsRehash($hash); // true once parameters changebcrypt silently truncates at 72 bytes, which turns a long passphrase into a much weaker secret without telling anyone. hash() throws rather than accepting input it cannot represent faithfully. Say so in your form's validation.
Two timing defences
Unknown accounts still cost a verification
When findByEmail() returns nothing, attempt() runs a decoy verification against a fixed hash. Without it, a failed lookup returns in microseconds and a successful one takes the full hashing time — which is enough to enumerate registered addresses.
Comparisons are constant-time
Tokens are compared with hash_equals, and passwords via password_verify. Neither reveals how much of the value was correct.
Throttling
<?php
$throttle = new LoginThrottle($database, maxAttempts: 5, windowSeconds: 900);
// The key is yours to build — the throttle stores it hashed and counts it.
$key = mb_strtolower($email) . '|' . ($request->headers->first('X-Forwarded-For') ?? 'local');
$throttle->tooManyAttempts($key); // bool
$throttle->attempts($key); // int, within the window
$throttle->retryAfter($key); // seconds until the window clears
$throttle->record($key); // after a failure
$throttle->clear($key); // after a success
$throttle->purge(); // housekeeping, for cronLOGIN_MAX_ATTEMPTS=5
LOGIN_WINDOW_SECONDS=900Keying on the address alone would let one attacker lock out an office behind a shared IP. Keying on the email alone lets an attacker lock a victim out of their own account deliberately. Both together throttles the actual attack without offering a denial-of-service lever, and the key is stored hashed so the attempts table is not itself a list of accounts to target.
Wiring it up
<?php
Application::boot(static function (Blueprint $app) use ($env): void {
$app->container->singleton(PasswordHasher::class, static fn (): PasswordHasher => new PasswordHasher());
$app->container->singleton(
UserProvider::class,
static fn (Container $c): UserProvider => new DatabaseUserProvider($c->get(Connection::class)),
);
// Scoped: it reads the session, which belongs to this request.
$app->container->scoped(
Authenticator::class,
static fn (RequestScope $scope): Authenticator => new Authenticator(
$scope->get(Session::class),
$scope->get(UserProvider::class),
$scope->get(PasswordHasher::class),
),
);
});In templates
@if($currentUser !== null)
<span>{{ $currentUser->displayName }}</span>
<form method="post" action="/logout">
<input type="hidden" name="_token" value="{{ $csrfToken }}">
<button type="submit">Sign out</button>
</form>
@else
<a href="/login">Sign in</a>
@endifSign-out is a POST, not a link — a GET logout can be triggered by any image tag on any page.
Not included
Registration, password reset and email are yours to write; they need decisions about verification and delivery that a framework should not make for you. The pieces above are what they build on: hash with PasswordHasher, store through your provider, and call login() when the user is confirmed.