yahyaerturan / auth
Framework-agnostic PHP 8.5 authentication core: password authentication, server-side sessions, password reset, email verification, API tokens, rate limiting and audit/security events.
Requires
- php: ^8.5
- psr/clock: ^1.0
- psr/event-dispatcher: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.90
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.3
- yahyaerturan/auth-testing: ^1.0
Suggests
- yahyaerturan/auth-authorization: Optional RBAC and resource-policy authorization, if the application needs it.
- yahyaerturan/auth-pdo: Portable PDO persistence adapters and reference migrations for SQLite, MySQL/MariaDB and PostgreSQL.
- yahyaerturan/auth-psr15: PSR-15 middleware, session-token extraction and secure session-cookie handling.
- yahyaerturan/auth-testing: In-memory adapters, deterministic clock and randomness, and the reusable repository contract suites.
Provides
None
Conflicts
None
Replaces
None
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
requirein 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 conditionalUPDATE, 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.
AuditRepositoryhas one method,append(). No update, no delete, no purge, no reader. The adapters emit a plainINSERT, 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:
RESTRICTwould make a routine session purge fail against audit rows, andCASCADEwould erase the record of what a deleted user did.The failure policy is named at the composition site, and it does not overclaim.
BestEffortcannot be silent — its observer is a mandatory argument — andRequired'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.
Userhas nohasRole(), nocan()and noallows()— an architecture test pins its whole public surface — and norequireanywhere 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
pending→activetransition 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 nameyahyaerturan/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 declaresstrict_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.