Search by

Framework-agnostic PHP 8.5 authentication core: password authentication, server-side sessions, password reset, email verification, API tokens, rate limiting and audit/security events.

Package info

github.com/yahyaerturan/auth

pkg:composer/yahyaerturan/auth

Statistics

Installs: 24

Dependents: 4

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-09-08 19:18 UTC

This package is auto-updated.

Last update: 2026-09-08 19:18:27 UTC


README

A small, explicit, framework-agnostic authentication core for PHP 8.5, built as ports and adapters with dependencies pointing inward.

composer require yahyaerturan/auth

Requires PHP 8.5 or newer (^8.5). Its only runtime dependencies are two interface packages — psr/clock and psr/event-dispatcher. No framework, no ORM, no PDO, no HTTP, no logger.

What you get

Password authentication, opaque server-side sessions, password reset and email verification, registration and the account lifecycle, long-lived API tokens, rate limiting, and an append-only audit trail — as use cases and ports, with no opinion about where anything is stored or how requests reach you.

Persistence, HTTP and authorization are separate packages you add only if you want them. See the ecosystem.

Minimal usage

The core owns behaviour and the interfaces it needs; you supply the adapters. $users and $credentials below are repository ports — install yahyaerturan/auth-pdo for a relational implementation, or write your own.

<?php

declare(strict_types=1);

use YahyaErturan\Auth\Authentication\AuthenticatePassword;
use YahyaErturan\Auth\Authentication\AuthenticatePasswordCommand;
use YahyaErturan\Auth\Credential\AsciiCaseInsensitiveIdentifierNormalizer;
use YahyaErturan\Auth\Credential\PasswordHashingOptions;
use YahyaErturan\Auth\RateLimit\RateLimitPolicy;
use YahyaErturan\Auth\RateLimit\RateLimitRule;
use YahyaErturan\Auth\RateLimit\RateLimitScope;
use YahyaErturan\Auth\Security\DummyPasswordHash;
use YahyaErturan\Auth\Security\NativeCryptographicRandom;
use YahyaErturan\Auth\Security\NativePasswordHasher;
use YahyaErturan\Auth\Support\SystemClock;

$clock  = new SystemClock();
$hasher = new NativePasswordHasher(PasswordHashingOptions::argon2id());
$scope  = RateLimitScope::named('password.login');

$login = new AuthenticatePassword(
    $users,          // YahyaErturan\Auth\Contract\UserRepository
    $credentials,    // YahyaErturan\Auth\Contract\CredentialRepository
    new AsciiCaseInsensitiveIdentifierNormalizer(),
    $hasher,
    DummyPasswordHash::generate($hasher, new NativeCryptographicRandom()),
    $rateLimiter,    // YahyaErturan\Auth\Contract\RateLimiter
    [
        RateLimitRule::perIdentifier($scope, RateLimitPolicy::referenceIdentifierLogin()),
        RateLimitRule::perClient($scope, RateLimitPolicy::referenceClientLogin()),
    ],
    $clock,
);

$result = $login->execute(new AuthenticatePasswordCommand('ada@example.com', $password));

if ($result->isSuccess()) {
    $user = $result->identity();
}

Three things in that snippet are the whole design argument:

A denial is a result, not an exception. $result is one of AuthenticationSuccess, AuthenticationFailure or AuthenticationRateLimited. A wrong password is an ordinary outcome and reads like one; an exception here would mean a failed login and an unreachable database looked the same.

The decoy hash is a constructor argument. DummyPasswordHash::generate() makes the work done for an unknown identifier match the work done for a known one, so response time does not reveal which accounts exist. It is generated once, from randomness, at composition — never per request.

Time and randomness are injected. SystemClock and NativeCryptographicRandom are the only implementations that read ambient state, and an architecture test refuses time() and random_bytes() everywhere else. That is what makes an expiry boundary testable rather than hopeful.

Adding a session

AuthenticationSuccess is proof of a password check, not a login. Issue a session from it with CreateSession, and authenticate later requests with AuthenticateSession — see docs/SESSIONS.md, and yahyaerturan/auth-psr15 if those requests arrive over HTTP.

Where to go next

Before deploying docs/SECURITY_CONFIGURATION.md — every decision a deployment has to make, and what this library refuses to decide for you
Primitives, identity, credentials docs/CORE_PRIMITIVES.md
Password login, results, rate limiting docs/PASSWORD_AUTHENTICATION.md
Sessions docs/SESSIONS.md
Password reset, email verification docs/ONE_TIME_TOKENS.md
Registration, password change docs/ACCOUNT_LIFECYCLE.md
API tokens docs/API_TOKENS.md
Events and audit docs/AUDIT_AND_OBSERVABILITY.md
Writing your own adapter docs/WRITING_AN_ADAPTER.md
Contributing CONTRIBUTING.md

Status and security properties

1.0.0 — released 2026-09-08. What follows is the detail behind that: what exists, and the specific guarantees each part is built to hold. It is written for somebody deciding whether to trust this library, and can be skipped on a first read. The evidence the release was accepted on is in docs/V1_RELEASE_READINESS.md.

Status: v1.0.0. Phase 10 (final hardening) complete. The identity model, the clock and randomness boundaries, password credentials and hashing, password login, opaque server-side sessions, the first-party relational adapter for SQLite, MySQL, MariaDB and PostgreSQL, one-time tokens — password reset and email verification, consumed exactly once under concurrency — HTTP integration through a PSR-15 middleware with no framework anywhere and no PSR-7 symbol in the core, roles, permissions and resource policies in an optional package the rest of the library does not depend on, a security-event model and an append-only audit trail, and now long-lived, labelled, revocable API tokens.

An API token is a session with a different lifecycle — not a JWT. A signed self-contained token cannot be revoked before it expires without a server-side revocation list, at which point it is a server-side token carrying extra cryptography. No require in this repository names a JWT, JOSE, PASETO or Branca package, and a test keeps it that way.

Disabling an account stops every one of its API tokens instantly. Authentication re-reads the identity and re-checks eligibility on every single presentation, so there is no sweep to run, no cache to invalidate and no token row to touch — and re-enabling the account restores them, because nothing was destroyed.

Revocation reports whether it revoked. revoke() returns a boolean taken from one conditional UPDATE, so the audit trail records a credential stopping work exactly once, at the instant it happened — even when two callers race, and under both of MySQL's row-count modes.

API tokens survive a password change; sessions do not. A session is a credential the password created. An API token is one the user created for a machine, and revoking every integration whenever somebody rotates a password would punish the behaviour security guidance encourages.

A revoked session can no longer mint anything. Phase 10's review found that a completed SessionAuthenticationSuccess — a value with no expiry that serializes cleanly — could still issue a ninety-day API token after the session it described had been revoked. The three use cases that take one now revalidate the session at the point of use, so "log out everywhere" is a complete boundary for what that session could create (ADR-081).

Six findings, all fixed, each with a regression test and a structural guard that fails the build if the defect returns. Fifteen deliberate mutations of the cross-cutting security invariants, all fifteen caught. Five consecutive full runs plus randomised, reversed and dependency ordering: identical every time, with no skips. See docs/V1_RELEASE_READINESS.md.

Sixteen security events, identified by stable codes rather than class names, dispatched through PSR-14 and composed explicitly. Each event declares its own audit projection by hand: nothing reflects over an event's properties or serialises one, so the complete set of fields that can reach the audit table is the four keys the source lists.

AuditRepository has one method, append(). No update, no delete, no purge, no reader. The adapters emit a plain INSERT, so a duplicate identifier is refused rather than overwriting the record it collided with — proved concurrently on all three engines.

Audit history has no foreign keys, on purpose. It must be able to outlive the sessions and identities it names: RESTRICT would make a routine session purge fail against audit rows, and CASCADE would erase the record of what a deleted user did.

The failure policy is named at the composition site, and it does not overclaim. BestEffort cannot be silent — its observer is a mandatory argument — and Required's documentation says first that it does not roll the protected mutation back, because events are dispatched after the commit. Genuine atomicity is a documented composition on one connection, proved on three engines including the failure direction.

No metrics client, no logger, no tracing SDK. Observability integration is guidance plus the events that make it actionable; a dependency no production code uses is a false claim about what you must install.

Business code asks about permissions, never role names. User has no hasRole(), no can() and no allows() — an architecture test pins its whole public surface — and no require anywhere in the repository names the authorization package, so installing the PDO adapter installs no authorization at all.

A resource policy can narrow a permission; it can never mint one. When RBAC denies, the policy registry is not consulted — the policies are not asked and then overruled, they are never asked — so a policy that would allow everything cannot create access, however it is written.

Permission revocation takes effect on the next check, on the same object. There is no authorization cache and nothing to invalidate: the documented stale window is zero, proved across two connections on three engines, and a reflection guard fails if the service ever gains a property that could become one.

Raw bearer possession is the authentication evidence; a stolen database of token digests is not. Sessions and one-time tokens are stored as SHA-256 digests of 256-bit random tokens, and tests search every column of a real database for a known token to prove it.

A one-time token is spent exactly once. Consumption is a single compare-and-set, and two consumers that both observed the same usable token are driven deterministically through it — on the in-memory double and on three real engines — with exactly one winner.

A verification link cannot undo an administrative decision. The pendingactive transition is one conditional update, so a link issued before an account was disabled cannot re-enable it hours later.

A password reset revokes sessions that do not exist yet, too. An authentication assertion is bound to a fingerprint of the credential state it was made from, and the session it issues keeps that binding for its whole life — so a reset committing while a session is being written leaves that session unusable on its first request, and no shorter window, retry or clock comparison is involved.

"That address is taken" is said only when it is true. A registration transaction can break several uniqueness invariants, and exactly one of them means the identifier is registered; the rest — a colliding generated identifier, a repeated token digest — are raised as the defects they are rather than reported to a user as a validation message.

A password can only be replaced by the caller who observed it. One conditional update, so a password change cannot silently overwrite a reset that committed while it was hashing — and neither can the automatic hash upgrade a login performs in the background, which is the same race wearing overalls.

Strengthening your KDF parameters does not log anybody out. A credential carries a password generation, separate from the hash that represents it, so re-hashing the same password is maintenance while replacing it invalidates every session that password authorized.

One behavioural contract governs every adapter: the same repository suites run against the in-memory doubles and all three engines, and they include referential integrity — a credential, session or token cannot be stored for an identity that does not exist, on any adapter.

What exists today is documented in docs/CORE_PRIMITIVES.md (primitives, identity and credentials), docs/PASSWORD_AUTHENTICATION.md (password login, results, rate limiting), docs/SESSIONS.md (bearer tokens, session lifecycle, revocation) and auth-pdo: PERSISTENCE.md (drivers, schema, migrations, timestamps, collation, exception translation, transactions) and docs/ONE_TIME_TOKENS.md (password reset, email verification, compare-and-set consumption, the delivery boundary) and docs/ACCOUNT_LIFECYCLE.md (registration, password change, the credential compare-and-set, and the freshness rule session issuance now applies) and auth-psr15: HTTP_INTEGRATION.md (the PSR-15 middleware, the authentication context, token extraction, cookie security, CSRF responsibility and long-running-process safety) and auth-authorization: AUTHORIZATION.md (roles, permissions, the permission-first API, resource policies, composition and default deny, revocation semantics and the optional-package boundary) and docs/AUDIT_AND_OBSERVABILITY.md (the event catalogue, the append-only audit record, the metadata model, failure policy and atomicity, retention and privacy, and the metrics, logging and tracing guidance that deliberately ships no dependency) and docs/API_TOKENS.md (issuance, authentication and revocation, the four digest domains, mandatory expiry, threshold last-used writes, and the three things a v1 token deliberately does not do).

Before deploying it, read docs/SECURITY_CONFIGURATION.md — everything a deployment has to decide, what the defaults are, and what this library deliberately refuses to decide for you. If you are storing auth data somewhere these packages do not adapt to, read docs/WRITING_AN_ADAPTER.md, which is mostly about the shared contract suite you should be running against your implementation.

Two documents exist for reviewers rather than integrators: docs/RACE_MATRIX.md — every security-sensitive concurrent transition, its linearization point, who wins and the test that proves it — and docs/PHASE_10_HARDENING_LEDGER.md, the normative compliance ledger, threat-model review and findings register.

Runnable, framework-free integrations live with the package each one composes, and every one is executed by its repository's test suite on every run, so none can drift from the API it documents:

Example Repository
examples/api-token/ here
examples/PlainPsr15Example.php auth-psr15
examples/PlainAuthorizationExample.php auth-authorization

The full normative specification lives in php-auth-library-spec-pack/ and is authoritative. Implementation-time decisions are recorded in docs/adr/; the phase plan derived from the spec is in docs/IMPLEMENTATION_MAP.md.

Design commitments

  • The core owns behaviour and the interfaces it needs. Infrastructure implements them. Frameworks compose the two.
  • No generic StorageInterface. Persistence contracts are domain-specific: UserRepository, CredentialRepository, SessionRepository, OneTimeTokenRepository, RoleRepository, PermissionRepository, AuthorizationGrantRepository, AuditRepository.
  • No ORM, no framework, no PDO, no Redis, no HTTP and no superglobals in the core.
  • Time and randomness are injected, never ambient.
  • Raw bearer secrets are never persisted; only one-way digests are stored.
  • Authentication denial is a result type, not an exception.
  • Security history is append-only through the public API, and the word "immutable" is not made to carry more than the code can: a database administrator can still edit a row, and the documentation says so.
  • Authentication and authorization stay separable — structurally, not by convention: the core names no authorization symbol, and nothing requires the authorization package at runtime.
  • No magical runtime discovery. No class scanning, no attribute registration, no container lookup: policies are a constructor argument, so what can affect an access decision is what somebody wrote down.

The ecosystem

yahyaerturan/auth is the core. Four companion packages adapt it to a technology, and each is a separate repository and a separate Composer package, versioned and released on its own.

Composer package Repository Namespace What it is
yahyaerturan/auth https://github.com/yahyaerturan/auth YahyaErturan\Auth\ this repository — the authentication core
yahyaerturan/auth-pdo https://github.com/yahyaerturan/auth-pdo YahyaErturan\Auth\Pdo\ PDO persistence and reference migrations for SQLite, MySQL/MariaDB and PostgreSQL
yahyaerturan/auth-psr15 https://github.com/yahyaerturan/auth-psr15 YahyaErturan\Auth\Psr15\ PSR-15 middleware, session-token extraction, secure session cookies
yahyaerturan/auth-authorization https://github.com/yahyaerturan/auth-authorization YahyaErturan\Authz\ optional RBAC and resource policies
yahyaerturan/auth-testing https://github.com/yahyaerturan/auth-testing YahyaErturan\Auth\Testing\ in-memory adapters, deterministic sources, and the reusable repository contracts

Install only what you need:

composer require yahyaerturan/auth                    # the core alone: a complete library
composer require yahyaerturan/auth-pdo                # + relational persistence
composer require yahyaerturan/auth-psr15              # + HTTP integration
composer require yahyaerturan/auth-authorization      # + roles and permissions
composer require --dev yahyaerturan/auth-testing      # + test doubles and contracts

Dependencies point inward, and only inward.

              auth-pdo ─┐
            auth-psr15 ─┼──> yahyaerturan/auth
    auth-authorization ─┤
           auth-testing ─┘

Every companion requires the core. No companion requires another companion at runtime. The core requires none of them — its entire runtime surface is php: ^8.5, psr/clock and psr/event-dispatcher, both interface-only, and both genuinely imported by production code (ADR-014, ADR-064). There is no logger, no metrics client and no tracing SDK, in any package.

auth-pdo ships relational adapters for the authorization ports and declares auth-authorization as a suggest, never a require: installing a database adapter must not install an authorization model. Applications using those classes install the authorization package explicitly.

Versioning

SemVer, per package. The packages no longer share a release number: each repository has its own tags, its own changelog and its own release cadence, and each companion declares the core at a ^1.0-style constraint that says what it is compatible with.

A vulnerability spanning packages is fixed by a coordinated release of each affected one, and the advisory names all of them — see SECURITY.md.

Layout

This repository is the yahyaerturan/auth package — see ADR-011.

/
├── composer.json          # yahyaerturan/auth
├── src/                   # YahyaErturan\Auth\        — the core library
├── tests/                 # YahyaErturan\Auth\Tests\  — unit tests and build guards
├── examples/              # executable plain-PHP compositions
├── docs/                  # core documentation and the ADR record
└── tools/                 # release-rehearsal tooling

There is no packages/ directory. There was, until the companions were extracted into repositories of their own; developing them means cloning those repositories, not editing a subdirectory here.

Working across several packages at once

Clone the ones you need as siblings under one parent:

your-workspace/
├── auth/
├── auth-pdo/
├── auth-psr15/
├── auth-authorization/
└── auth-testing/

then point Composer at the checkouts without committing anything:

cp composer.json composer.dev.json
composer config --file composer.dev.json repositories.siblings \
    '{"type":"path","url":"../auth*","options":{"symlink":true}}'
composer config --file composer.dev.json minimum-stability dev
COMPOSER=composer.dev.json composer update

composer.dev.json and composer.dev.lock are git-ignored, and a build guard fails if a repositories block ever appears in the committed manifest: a published package that names a path is installable only on the machine that has that path.

Run each package's tests from its own root, with composer qa. Every repository has the same gate — composer validate --strict, check-platform-reqs, php-cs-fixer, PHPStan at max, PHPUnit — and each runs it against its own source with only its own declared dev dependencies.

Requirements

PHP 8.5 (^8.5). PHP 8.4 and older are intentionally unsupported and no compatibility shims will be accepted — see ADR-012.

If the php on your PATH is older, put a PHP 8.5 binary first on PATH for this repository, or invoke the toolchain through it explicitly:

PHP85="$(command -v php8.5 || echo "$(brew --prefix php)/bin/php")"
"$PHP85" -r 'echo PHP_VERSION, PHP_EOL;'   # confirm before running the gate
"$PHP85" "$(command -v composer)" install
"$PHP85" vendor/bin/phpunit

config.platform.php = 8.5.0 makes Composer resolve against the declared minimum whichever interpreter invokes it. It is not evidence that the code runs on 8.5 — only actually running the tools under a PHP 8.5 runtime is, which is what PhpVersionBaselineTest asserts and what CI provides.

Quality gates

Every phase must leave all four green before the next phase begins.

composer platform   # composer check-platform-reqs
composer cs         # coding standard (PHP CS Fixer, PSR-12 superset)
composer stan       # static analysis (PHPStan, level max + strict rules)
composer test       # PHPUnit
composer qa         # all of the above, in order

This gate needs no database. The core has no persistence, so its suite composes everything in memory — which is the point rather than a convenience: a core test that needed a driver would be a core that knew about one.

The database matrix belongs to yahyaerturan/auth-pdo, and is run from that repository. It covers SQLite, MySQL 8.4, MariaDB 12.3 and PostgreSQL 17/18, and CI there asserts the absence of skips per suite per engine — because a suite whose DSN is unset skips, and a skipped suite is indistinguishable from a passing one in an aggregate run.

MySQL and MariaDB are not the same engine and this project does not claim they are. They share a PDO driver name, which is not evidence of anything; each is exercised separately. Any other driver is refused at construction with UnsupportedDriver.

Tooling beyond the gate

php tools/benchmark.php              # regression-awareness numbers, not a CI gate
tools/package-combinations.sh        # install each intended package combination
                                     # from the sibling repositories, and smoke-test it

tools/package-combinations.sh is the release rehearsal: it builds a throwaway project per intended combination, installs the packages from local clones tagged with a candidate version, and instantiates the classes each combination is meant to provide. It answers the one question no single repository's test suite can — does composer require yahyaerturan/auth give me a working library, on its own, with nothing else on disk?

Architecture enforcement

tests/Architecture/ enforces the specification's invariants mechanically rather than by review alone:

  • CoreDependencyDirectionTest — the core names no framework, driver, HTTP abstraction, superglobal, ambient clock, ambient randomness, implicit date parsing or global state; declares no dependency outside the approved PSR surface; and depends on no companion package, while each companion depends on the core and on no other companion.
  • CoreBoundaryScannerTest — proves the scanner above actually fires, so it cannot degrade into a vacuous pass.
  • CoreBoundaryExemptionTest — for every entry in the central exemption allowlist (tests/Support/CoreBoundaryPolicy.php), proves the sanctioned port adapter may make its call, that identical code in ordinary core logic is still rejected, and that the exemption widens to no other rule.
  • PackageManifestTest — package, namespace, autoload and path-repository layout, including that no second package claims the name yahyaerturan/auth.
  • PhaseScopeTest — phase discipline: the core contains only the namespaces completed phases own, and no class from a later phase has been scaffolded.
  • StrictTypesTest — every PHP file declares strict_types=1.
  • tests/Baseline/PhpVersionBaselineTest — the PHP 8.5 runtime guard.

Contributing

Issues and pull requests are welcome — see CONTRIBUTING.md for the setup, the quality gate, the architecture guards and the conventions this project expects. Participation is governed by the Code of Conduct.

Open an issue before anything that changes behaviour or public API: several things that look like obvious improvements were considered and deliberately declined, with the reasoning in docs/adr/.

Found a security vulnerability? Do not open an issue or a pull request — read SECURITY.md and report it privately.

Licence

MIT. See LICENSE. Security policy: SECURITY.md.